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

    Привет, Хабр!

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

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

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

    Спасибо за внимание.

    Похожие публикации

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 1

      0
      браво!

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое