El poder de PWA: un sistema de videovigilancia con un código JS de red neuronal de 300 líneas

Hola Habr!

Los navegadores web implementan lenta pero seguramente la mayoría de las características del sistema operativo, y cada vez hay menos razones para desarrollar una aplicación nativa si puede escribir una versión web (PWA). Multiplataforma, API rica, alta velocidad de desarrollo en TS / JS e incluso el rendimiento del motor V8: todo esto es una ventaja. Los navegadores han sido capaces de trabajar con una transmisión de video y ejecutar redes neuronales, es decir, tenemos todos los componentes para crear un sistema de video vigilancia con reconocimiento de objetos. Inspirado en este artículo , decidí llevar la demostración al nivel de aplicación práctica, que quiero compartir.

La aplicación graba video de la cámara, enviando periódicamente cuadros para su reconocimiento en el COCO-SSD, y si se detecta a una persona, los fragmentos de video en porciones de 7 segundos comienzan a enviarse al correo electrónico especificado a través de la API de Gmail. Al igual que en los sistemas para adultos, se realiza una pregrabación, es decir, guardamos un fragmento hasta el momento de la detección, todos los fragmentos con detección y uno posterior. Si Internet no está disponible o se produce un error durante el envío, los videos se guardan en la carpeta de descargas local. El uso del correo electrónico le permite prescindir del lado del servidor, notificar instantáneamente al propietario, y si un atacante tomó posesión del dispositivo y descifró todas las contraseñas, no podrá eliminar el correo del destinatario. De las desventajas: el tráfico se desborda debido a Base64 (aunque es suficiente para una cámara) y la necesidad de recopilar el archivo de video final de muchos correos electrónicos.

La demostración de trabajo está aquí .

Los problemas encontrados son los siguientes:

1) La red neuronal carga mucho el procesador, y si lo ejecuta en el hilo principal, aparecen retrasos en los videos. Por lo tanto, el reconocimiento se coloca en un hilo separado (trabajador), aunque no todo es fácil aquí. En Linux prehistórico de doble núcleo, todo es perfectamente paralelo, pero en algunos teléfonos móviles de 4 núcleos bastante nuevos: en el momento del reconocimiento (en el trabajador), el hilo principal también comienza a retrasarse, lo que se nota en la interfaz de usuario. Afortunadamente, esto no afecta la calidad del video, aunque reduce la frecuencia de reconocimiento (se ajusta automáticamente a la carga). Este problema probablemente esté relacionado con la forma en que las diferentes versiones de Android distribuyen los hilos por núcleo, la presencia de SIMD, las funciones disponibles de la tarjeta de video, etc. No puedo resolverlo por mi cuenta, no conozco el interior de TensorFlow y agradeceré la información.

2) FireFox. La aplicación funciona bien en Chrome / Chromium / Edge, sin embargo, el reconocimiento en FireFox es notablemente más lento, además, ImageCapture todavía no está implementado (por supuesto, esto se puede evitar al capturar un marco desde <video>, pero es una pena para el zorro, porque es estándar API). En general, tampoco hubo accesibilidad completa entre navegadores.

Entonces, todo en orden.

Conseguir una cámara y un micrófono


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

Aquí seleccionamos la cámara principal del teléfono móvil / tableta (o la primera en la computadora / computadora portátil), mostramos la transmisión en un reproductor de video estándar, luego de lo cual esperamos que se carguen los metadatos y establecemos las dimensiones del lienzo de servicio. Como toda la aplicación está escrita en el estilo de async / await, debe convertir las API de devolución de llamada (y hay muchas) a Promise para lograr uniformidad.

Captura de video


Hay dos formas de capturar videos. El primero es leer directamente los fotogramas de la transmisión entrante, mostrarlos en el lienzo, modificarlos (por ejemplo, agregar geo y marcas de tiempo) y luego tomar los datos del lienzo: para el grabador como una transmisión saliente y para una red neuronal como imágenes separadas. En este caso, puede prescindir del elemento <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))
}

La segunda forma (trabajando en FF) es usar un reproductor de video estándar para capturar. Por cierto, consume menos tiempo de procesador, a diferencia de la visualización cuadro por cuadro en el lienzo, pero no podemos agregar una inscripción.

...
async function grab_video() {
	this.canvas.drawImage(this.video, 0, 0)
	...
}

La aplicación utiliza la primera opción, por lo que el reproductor de video se puede apagar durante el proceso de reconocimiento. Para guardar el procesador, la grabación se realiza desde la transmisión entrante, y los marcos de dibujo en el lienzo se usan solo para obtener una matriz de píxeles para la red neuronal, con una frecuencia que depende de la velocidad de reconocimiento. Dibujamos el marco alrededor de la persona en un lienzo separado colocado en el jugador.

Carga de redes neuronales y detección humana


Todo es indecentemente simple. Iniciamos al trabajador , después de cargar el modelo (bastante largo), enviamos un mensaje vacío al hilo principal, donde en el evento onmessage mostramos el botón de inicio, después de lo cual el trabajador está listo para recibir imágenes. Código de trabajador completo:

(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})
  }
})()

En el hilo principal, comenzamos la función grab_video () solo después de recibir el resultado anterior del trabajador, es decir, la frecuencia de detección dependerá de la carga del sistema.

Grabación de vídeo


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)

Cada vez que se detiene la grabadora (usamos un intervalo fijo), se genera el evento ondataavailable, donde se transfiere el fragmento grabado en formato Blob, se guarda en this.chunk y se envía de forma asincrónica. Sí, this.send_chunk () devuelve una promesa, pero la función lleva mucho tiempo (codificar en Base64, enviar un correo electrónico o guardar el archivo localmente), y no esperamos que se ejecute y no procese el resultado; por lo tanto, no hay que esperar. Incluso si resulta que los nuevos videoclips aparecen con más frecuencia de lo que se pueden enviar, el motor JS organiza la línea de promesas de forma transparente para el desarrollador, y todos los datos se enviarán / escribirán tarde o temprano. Lo único que vale la pena prestar atención es dentro de la función send_chunk () antes de la primera espera, debe clonar el Blob con el método slice (), ya que el enlace this.chunk se borra cada CHUNK_DURATION segundos.

API de Gmail


Se usa para enviar cartas. La API es bastante antigua, en parte por promesas, en parte por devoluciones de llamada, la documentación y los ejemplos no son abundantes, por lo que daré el código completo.

Autorización . obtenemos la aplicación y las claves del cliente en la consola de desarrolladores de Google. En una ventana emergente de autorización, Google informa que la aplicación no ha sido verificada, y tendrá que hacer clic en "configuración avanzada" para ingresar. Comprobar que la aplicación en Google resultó ser una tarea no trivial, debe confirmar la propiedad del dominio (que no tengo), organizar correctamente la página principal, por lo que decidí no molestarme.

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)
   }
})

Envío de correo electrónico . Las cadenas codificadas en Base64 no se pueden concatenar, y esto es inconveniente. Cómo enviar video en formato binario, todavía no lo entendí. En las últimas líneas, convertimos la devolución de llamada en una promesa. Lamentablemente, esto debe hacerse con bastante frecuencia.

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)
      })
   })
}

Guardar un videoclip en el disco. Usamos un hipervínculo oculto.

const a = this.querySelector('a')
URL.revokeObjectURL(a.href)
a.href = URL.createObjectURL(chunk)
a.download = name
a.click()

Gestión del estado en el mundo de los componentes web.


Continuando con la idea presentada en este artículo , la llevé al absurdo del fin lógico (solo para lulz) y volteé el control del estado. Si generalmente las variables JS se consideran como un estado, y el DOM es solo la pantalla actual, entonces en mi caso la fuente de datos es el DOM en sí (dado que los componentes web son los nodos DOM de larga duración), y para usar datos en el lado JS, los componentes web proporcionan captadores / setters para cada campo de formulario. Entonces, por ejemplo, en lugar de casillas de verificación incómodas en el estilo, se usa un simple <botón>, y el "valor" del botón (verdadero se presiona, falso se presiona) es el valor del atributo de clase, que le permite darle un estilo como este:

button.true {background-color: red}

y obtener el valor de esta manera:

get detecting() { return this.querySelector('#detecting').className === 'true' }

No puedo recomendar usar esto en la producción, porque esta es una buena manera de deshacerse de la productividad. Aunque ... el DOM virtual tampoco es gratuito, y no hice puntos de referencia.

Modo offline


Finalmente, agregue un poco de PWA, es decir, instale un trabajador de servicio que almacenará en caché todas las solicitudes de red y permitirá que la aplicación funcione sin acceso a Internet. Un pequeño matiz: en los artículos sobre trabajadores de servicios, generalmente dan el siguiente algoritmo:

  • En el evento de instalación, cree una nueva versión del caché y agregue todos los recursos necesarios al caché.
  • En el evento de activación, elimine todas las versiones de la memoria caché, excepto la actual.
  • En el evento fetch, primero intentamos tomar el recurso del caché y, si no lo encontramos, enviamos una solicitud de red, cuyo resultado se agrega al caché.

En la práctica, tal esquema es inconveniente por dos razones. En primer lugar, en el código del trabajador debe tener una lista actualizada de todos los recursos necesarios, y en proyectos grandes que usan bibliotecas de terceros, intente realizar un seguimiento de todas las importaciones adjuntas (incluidas las dinámicas). El segundo problema: al cambiar cualquier archivo, debe aumentar la versión del trabajador de servicio, lo que conducirá a la instalación de un nuevo trabajador y a la invalidación del anterior, y esto sucederá SOLO cuando el navegador se cierre / abra. Una simple actualización de la página no ayudará: el antiguo trabajador con el antiguo caché funcionará. ¿Y dónde está la garantía de que mis clientes no mantendrán la pestaña del navegador para siempre? Por lo tanto, primero hacemos una solicitud de red, agregamos el resultado al caché de forma asíncrona (sin esperar la resolución de permisos cache.put (ev.request, resp.clone ())), y si la red no está disponible, lo obtenemos del caché. Mejor perder un díaluego vuela en 5 minutos ©.

Cuestiones no resueltas


  1. En algunos teléfonos móviles, la red neuronal se ralentiza, tal vez en mi caso, COCO-SSD no es la mejor opción, pero no soy un experto en ML y tomé la primera que se escuchó.
  2. No encontré un ejemplo de cómo enviar video a través de GAPI no en formato Base64, sino en el binario original. Esto ahorraría tiempo de procesador y tráfico de red.
  3. No entendí la seguridad. Para fines de depuración local, agregué el dominio localhost a la aplicación de Google, pero si alguien comienza a usar las claves de la aplicación para enviar correo no deseado, ¿Google bloqueará las claves o la cuenta del remitente?

Estaría agradecido por los comentarios.

Fuentes en github.

Gracias por la atención.

All Articles