Как стать автором
Обновить

Сила PWA: Система видеонаблюдения с нейросетью в 300 строчек JS-кода

Время на прочтение8 мин
Количество просмотров12K
Привет, Хабр!

Веб-браузеры медленно но верно реализуют большинство функций операционной системы, и остается все меньше причин разрабатывать нативное приложение, если можно написать веб-версию (PWA). Кроссплатформенность, богатое API, высокая скорость разработки на TS/JS, и даже производительность движка V8 — все идет в плюс. Браузеры уже давно умеют работать с видеопотоком и запускать нейронные сети, то есть мы имеем все компоненты для создания системы видеонаблюдения с распознаванием объектов. Вдохновленный этой статьей, я решил довести демо-пример до уровня практического применения, чем и хочу поделиться.

Приложение записывает видео с камеры, периодически отправляя кадры на распознавание в COCO-SSD, и если обнаружен человек — фрагменты видеозаписи порциями по 7 секунд начинают отправляться на указанный емейл через Gmail-API. Как и во взрослых системах — ведется предзапись, то есть мы сохраняем один фрагмент до момента детекции, все фрагменты с детекцией, и один после. Если интернет недоступен, или возникает ошибка при отправке — видеозаписи сохраняются в локальной папке Downloads. Использование емейла позволяет обойтись без серверной части, мгновенно оповестить хозяина, а если злоумышленник завладел устройством и взломал все пароли — он не сможет удалить почту у получателя. Из минусов — перерасход трафика за счет Base64 (хотя для одной камеры вполне хватает), и необходимость собирать итоговый видеофайл из множества емейлов.

Работающее демо здесь.

Проблемы возникли следующие:

1) Нейросеть сильно грузит процессор, и если запускать ее в основном треде — на видеозаписях появляются лаги. Поэтому распознавание помещаем в отдельный тред (воркер), хотя и тут не все гладко. На двухядерном доисторическом линуксе все отлично параллелится, но на некоторых достаточно новых 4-х ядерных мобильниках — в момент распознавания (в воркере) главный тред тоже начинает лагать, что заметно по пользовательскому интерфейсу. К счастью, это не отражается на качестве видеозаписи, хотя и снижает частоту распознавания (она автоматически подстраивается под нагрузку). Вероятно, эта проблема связана с тем, как разные версии Андроида распределяют треды по ядрам, наличием SIMD, доступными функциями видеокарты и т.д. В этом вопросе я не могу разобраться самостоятельно, внутренностей TensorFlow не знаю, и буду благодарен за информацию.

2) FireFox. Приложение отлично работает под Chrome / Chromium / Edge, однако в FireFox распознавание идет заметно медленней, кроме того, до сих пор не реализован ImageCapture (конечно, это можно обойти захватом кадра из <video>, но все равно за лису обидно, ведь это стандартное API). В общем полной кросс-браузерности как не было, так и нет.

Итак, все по порядку.

Получение камеры и микрофона


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.

Захват видео


Захватить видео можно двумя способами. Первый — непосредственно читать кадры из входящего стрима, отображать их на канвасе, модифицировать (например дорисовывать гео- и временные метки), и затем забирать данные с канваса — для рекордера в виде исходящего стрима, а для нейросети в виде отдельных изображений. В этом случае можно обойтись без элемента <video>.

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 — веб-компоненты предоставляют геттеры / сеттеры для каждого поля формы. Так, например, вместо неудобных в стилизации чекбоксов используются простые <button>, а «значением» кнопки (нажата 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 минут долететь ©.

Нерешенные проблемы


  1. На некоторых мобильниках тормозит нейросеть, возможно в моем случае COCO-SSD не лучший выбор, но я не специалист по ML, и взял первое что на слуху.
  2. Не нашел примера, как через GAPI отправить видео не в формате Base64, а в исходном бинарном. Это бы сэкономило и процессорное время и сетевой трафик.
  3. Не разобрался с безопасностью. В целях локальной отладки я добавил в гугл-приложение домен localhost, но если ключи приложения кто-то начнет использовать для рассылки спама — Гугл заблокирует сами ключи или аккаунт отправителя?

Буду благодарен за обратную связь.

Исходники на гитхабе.

Спасибо за внимание.
Теги:
Хабы:
+10
Комментарии1

Публикации

Истории

Работа

iOS разработчик
23 вакансии
React разработчик
61 вакансия
Data Scientist
62 вакансии
Swift разработчик
32 вакансии

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн