Сила PWA: cистема видеонаблюдения с нейросетью всего в 300 строчек JavaScript
Привет, Хабр!
Веб-браузеры медленно, но верно реализуют большинство функций операционной системы, и остается все меньше причин разрабатывать нативное приложение, когда можно сделать веб-версию (PWA). Кроссплатформенность, богатое API, высокая скорость разработки на TS/JS, и даже производительность движка V8 — все идет в плюс. Браузеры уже давно умеют работать с видеопотоком и запускать нейронные сети, то есть мы имеем все компоненты для создания системы видеонаблюдения с распознаванием объектов. Вдохновленный этой статьей, я решил довести демо-пример до уровня практического применения, чем и хочу поделиться.
Приложение записывает видео с камеры, периодически отправляя кадры на распознавание в COCO-SSD, и если обнаружен человек — фрагменты видеозаписи порциями по 7 секунд начинают отправляться на указанный емейл через Gmail-API. Как и во взрослых системах — ведется предзапись, то есть мы сохраняем один фрагмент до момента детекции, все фрагменты с детекцией, и один после. Если интернет недоступен, или возникает ошибка при отправке — видеозаписи сохраняются в локальной папке Downloads. Использование емейла позволяет обойтись без серверной части, мгновенно оповестить хозяина, а если злоумышленник завладел устройством и взломал все пароли — он не сможет удалить почту у получателя. Из минусов — перерасход трафика за счет Base64 (хотя для одной камеры вполне хватает), и необходимость собирать итоговый видеофайл из множества емейлов.
Работающее демо здесь.
Проблемы возникли следующие:
1) Нейросеть сильно грузит процессор, и если запускать ее в основном треде — на видеозаписях появляются лаги. Поэтому распознавание помещаем в отдельный тред (воркер), хотя и тут не все гладко. На двухядерном доисторическом линуксе все отлично параллелится, но на некоторых достаточно новых 4-х ядерных мобильниках — в момент распознавания (в воркере) главный тред тоже начинает лагать, что заметно по пользовательскому интерфейсу. К счастью, это не отражается на качестве видеозаписи, хотя и снижает частоту распознавания (она автоматически подстраивается под нагрузку). Вероятно, эта проблема связана с тем, как разные версии Андроида распределяет треды по ядрам, наличием SIMD, доступными функциями видеокарты и т.д. В этом вопросе я не могу разобраться самостоятельно, внутренностей TensorFlow не знаю, и буду благодарен за информацию.
2) FireFox. Приложение отлично работает под Chrome / Chromium / Edge, однако в FireFox распознавание идет заметно медленней, кроме того, до сих пор не реализован ImageCapture (конечно, это можно обойти захватом кадра из
Итак, все по порядку.
Получение камеры и микрофона
this.video = this.querySelector('video')
this.canvas = this.querySelectorAll('canvas')[0]
this.stream = await navigator.mediaDevices.getUserMedia(
{video: {facingMode: {ideal: "environment"}}, audio: true}
)
this.video.srcObject = this.stream
await new Promise((resolve, reject) => {
this.video.onloadedmetadata = (_) => resolve()
})
this.W = this.bbox.width = this.canvas.width = this.video.videoWidth
this.H = this.bbox.height = this.canvas.height = this.video.videoHeight
Здесь мы выбираем главную камеру мобильника / планшета (или первую у компьютера / ноутбука), отображаем поток в стандартном видеоплеере, после чего дожидаемся загрузки метаданных и устанавливаем размеры служебных канвасов. Поскольку все приложение написано в стиле async/await, приходится для единобразия преобразовывать callback-API (а таких достаточно много) в Promise.
Захват видео
Захватить видео можно двумя способами. Первый — непосредственно читать кадры из входящего стрима, отображать их на канвасе, модифицировать (например дорисовывать гео- и временные метки), и затем забирать данные с канваса — для рекордера в виде исходящего стрима, а для нейросети в виде отдельных изображений. В этом случае можно обойтись без элемента .
this.capture = new ImageCapture(this.stream.getVideoTracks()[0])
this.recorder = new MediaRecorder(this.canvas.captureStream(), {mimeType : "video/webm"})
grab_video()
async function grab_video() {
this.canvas.drawImage(await this.capture.grabFrame(), 0, 0)
const img = this.canvas.getImageData(0, 0, this.W, this.H)
... // если нейросеть свободна - отправляем ей img
... // модифицируем изображение - результат будет захвачен рекордером
window.requestAnimationFrame(this.grab_video.bind(this))
}
Второй способ (работающий в FF) — использовать для захвата стандартный видеоплеер. К слову сказать, он потребляет меньше процессорного времени в отличие от покадрового отображения на канвасе, но зато мы не можем добавить надпись.
...
async function grab_video() {
this.canvas.drawImage(this.video, 0, 0)
...
}
В приложении используется первый вариант, в результате чего видеоплеер можно отключать в процессе распознавания. В целях экономии процессора запись осуществляется из входящего стрима, а отрисовка кадров на канвасе используется только для получения массива пикселей для нейросети, с частотой, зависящей от скорости распознавания. Рамку вокруг человека рисуем на отдельном канвасе, наложенном на плеер.
Загрузка нейросети и обнаружение человека
Тут все до неприличия просто. Запускаем воркер, после загрузки модели (довольно длительного) отправляем пустое сообщение в главный тред, где в событии onmessage показываем кнопку старта, после чего воркер готов принимать изображения. Полный код воркера:
(async () => {
self.importScripts('https://cdn.jsdelivr.net/npm/@tensorflow/tfjs/dist/tf.min.js')
self.importScripts('https://cdn.jsdelivr.net/npm/@tensorflow-models/coco-ssd')
let model = await cocoSsd.load()
self.postMessage({})
self.onmessage = async (ev) => {
const result = await model.detect(ev.data)
const person = result.find(v => v.class === 'person')
if (person)
self.postMessage({ok: true, bbox: person.bbox})
else
self.postMessage({ok: false, bbox: null})
}
})()
В главном треде функцию grab_video () запускаем только после получения из воркера предыдущего результата, то есть частота детекции будет зависеть от загрузки системы.
Запись видео
this.recorder.rec = new MediaRecorder(this.stream, {mimeType : "video/webm"})
this.recorder.rec.ondataavailable = (ev) => {
this.chunk = ev.data
if (this.detected) {
this.send_chunk()
} else if (this.recorder.num > 0) {
this.send_chunk()
this.recorder.num--
}
}
...
this.recorder.rec.start()
this.recorder.num = 0
this.recorder.interval = setInterval(() => {
this.recorder.rec.stop()
this.recorder.rec.start()
}, CHUNK_DURATION)
При каждой остановке рекордера (мы используем фиксированный интервал) вызывается событие ondataavailable, куда передается записанный фрагмент в формате Blob, сохраняемый в this.chunk, и отправляемый асинхронно. Да, this.send_chunk () возвращает промис, но функция выполняется долго (кодирование в Base64, отправка емейла либо сохранение файла локально), и мы не ждем ее выполнения и не обрабатываем результат — поэтому отсутствует await. Даже если получается, что новые видеофрагменты появляются чаще, чем могут быть отправлены — движок JS выстраивает очередь промисов прозрачно для разработчика, и все данные рано или поздно будут отправлены / записаны. Единственно на что стоит обратить внимание — внутри функции send_chunk () до первого await нужно клонировать Blob методом slice (), так как ссылка this.chunk перетирается каждые CHUNK_DURATION секунд.
Gmail API
Используется для отправки писем. API довольно старое, часть на промисах, часть на колбэках, документация и примеры не обильны, поэтому приведу полный код.
Авторизация. ключи приложения и клиента получаем в консоли разработчика Google. Во всплывающем окне авторизации Гугл сообщает, что приложение не проверено, и для входа придется нажать «дополнительные настройки». Проверка приложения в Гугл оказалась задачей нетривиальной, нужно подтвердить право собственности на домен (которого у меня нет), правильно оформить главную страницу, поэтому я решил не заморачиваться.
await import('https://apis.google.com/js/api.js')
gapi.load('client:auth2', async () => {
try {
await gapi.client.init({
apiKey: API_KEY,
clientId: CLIENT_ID,
discoveryDocs: ['https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest'],
scope: 'https://www.googleapis.com/auth/gmail.send'
})
if (!gapi.auth2.getAuthInstance().isSignedIn.je) {
await gapi.auth2.getAuthInstance().signIn()
}
this.msg.innerHTML = ''
this.querySelector('nav').style.display = ''
} catch(e) {
this.msg.innerHTML = 'Gmail authorization error: ' + JSON.stringify(e, null, 2) + '
'
}
})
Отправка емейла. Строки, закодированные в Base64, нельзя конкатенировать, и это неудобно. Как отправить видео в бинарном формате я так и не разобрался. В последних строчках преобразуем колбэк в промис. Это к сожалению приходится делать довольно часто.
async send_mail(subject, mime_type, body) {
const headers = {
'From': '',
'To': this.email,
'Subject': 'Balajahe CCTV: ' + subject,
'Content-Type': mime_type,
'Content-transfer-encoding': 'base64'
}
let head = ''
for (const [k, v] of Object.entries(headers)) head += k + ': ' + v + '\r\n'
const request = gapi.client.gmail.users.messages.send({
'userId': 'me',
'resource': { 'raw': btoa(head + '\r\n' + body) }
})
return new Promise((resolve, reject) => {
request.execute((res) => {
if (!res.code)
resolve()
else
reject(res)
})
})
}
Сохранение видео-фрагмента на диск. Используем скрытую гиперссылку.
const a = this.querySelector('a')
URL.revokeObjectURL(a.href)
a.href = URL.createObjectURL(chunk)
a.download = name
a.click()
Управление стейтом в мире веб-компонентов
Продолжая идею, изложенную в этой статье, я довел ее до абсурда логического конца (for the lulz only) и перевернул управление стейтом с ног на голову. Если обычно стейтом считаются переменные JS, а DOM является лишь текущим отображением, то в моем случае источником данных является сам DOM (поскольку веб-компоненты это и есть долгоживущие узлы DOM), а для использования данных на стороне JS — веб-компоненты предоставляют геттеры / сеттеры для каждого поля формы. Так, например, вместо неудобных в стилизации чекбоксов используются простые, а «значением» кнопки (нажата true, отжата false) является значение атрибута class, что позволяет стилизовать ее примерно так:
button.true {background-color: red}
а получать значение так:
get detecting() { return this.querySelector('#detecting').className === 'true' }
Не могу советовать использовать такое в продакшене, ведь это хороший способ угробить производительность. Хотя… виртуальный DOM тоже не бесплатен, а бенчмарков я не делал.
Офлайн-режим
Напоследок добавим немного PWA, а именно установим сервис-воркер, который будет кэшировать все сетевые запросы, и позволит приложению работать без доступа к интернету. Маленький ньюанс — в статьях про сервис-воркеры обычно приводят следующий алгоритм:
- В событии install — создаем новую версию кэша и добавляем в кэш все необходимые ресурсы.
- В событии activate — удаляем все версии кэша кроме текущей.
- В событии fetch — сначала пытаемся взять ресурс из кэша, и если не нашли — отправляем сетевой запрос, результат которого складываем в кэш.
На практике такая схема неудобна по двум причинам. Во первых — в коде воркера нужно иметь актуальный список всех необходимых ресурсов, а в больших проектах с использованием сторонних библиотек — попробуй уследи за всеми вложенными импортами (включая динамические). Вторая проблема — при изменении любого файла нужно наращивать версию сервис-воркера, что приведет к инсталляции нового воркера и инвалидации предыдущего, и это произойдет ТОЛЬКО при закрытии / открытии браузера. Простое обновление страницы не поможет — будет работать старый воркер со старым кэшем. А где гарантия, что мои клиенты не будут держать вкладку браузера вечно? Поэтому сначала делаем сетевой запрос, результат складываем в кэш асинхронно (не дожидаясь разрешения промиса cache.put (ev.request, resp.clone ())), а если сеть недоступна — тогда достаем из кэша. Лучше день потерять, потом за 5 минут долететь ©.
Нерешенные проблемы
- На некоторых мобильниках тормозит нейросеть, возможно в моем случае COCO-SSD не лучший выбор, но я не специалист по ML, и взял первое что на слуху.
- Не нашел примера, как через GAPI отправить видео не в формате Base64, а в исходном бинарном. Это бы сэкономило и процессорное время и сетевой трафик.
- Не разобрался с безопасностью. В целях локальной отладки я добавил в гугл-приложение домен localhost, но если ключи приложения кто-то начнет использовать для рассылки спама — Гугл заблокирует сами ключи или аккаунт отправителя?
Буду благодарен за обратную связь.
Исходники на гитхабе.
Спасибо за внимание.