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

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

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

Веб-браузеры медленно но верно реализуют большинство функций операционной системы, и остается все меньше причин разрабатывать нативное приложение, если можно написать веб-версию (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, но если ключи приложения кто-то начнет использовать для рассылки спама — Гугл заблокирует сами ключи или аккаунт отправителя?

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

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

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

Публикации

Истории

Работа

Swift разработчик
25 вакансий
Data Scientist
83 вакансии
iOS разработчик
22 вакансии
React разработчик
54 вакансии

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

19 сентября
CDI Conf 2024
Москва
24 сентября
Конференция Fin.Bot 2024
МоскваОнлайн
30 сентября – 1 октября
Конференция фронтенд-разработчиков FrontendConf 2024
МоскваОнлайн