La puissance de PWA: un système de vidéosurveillance avec un code JS de réseau neuronal de 300 lignes

Bonjour, Habr!

Les navigateurs Web implémentent lentement mais sûrement la plupart des fonctionnalités du système d'exploitation, et il y a de moins en moins de raisons de développer une application native si vous pouvez écrire une version Web (PWA). Multiplateforme, API riche, vitesse de développement élevée sur TS / JS et même les performances du moteur V8 - tout cela est un plus. Les navigateurs peuvent depuis longtemps travailler avec un flux vidéo et exécuter des réseaux de neurones, c'est-à-dire que nous avons tous les composants pour créer un système de vidéosurveillance avec reconnaissance d'objets. Inspiré par cet article , j'ai décidé d'amener la démo au niveau d'application pratique, que je souhaite partager.

L'application enregistre la vidéo de la caméra, envoyant périodiquement des images pour reconnaissance dans le COCO-SSD, et si une personne est détectée, des fragments vidéo en portions de 7 secondes commencent à être envoyés à l'e-mail spécifié via l'API Gmail. Comme dans les systèmes pour adultes, le préenregistrement est effectué, c'est-à-dire que nous enregistrons un fragment jusqu'au moment de la détection, tous les fragments avec détection et un après. Si Internet n'est pas disponible ou qu'une erreur se produit lors de l'envoi, les vidéos sont enregistrées dans le dossier Téléchargements local. L'utilisation de l'e-mail vous permet de vous passer du côté serveur, d'informer instantanément le propriétaire et si un attaquant a pris possession de l'appareil et déchiffré tous les mots de passe, il ne pourra pas supprimer le courrier du destinataire. Parmi les inconvénients: le trafic dépassé en raison de Base64 (bien que cela soit suffisant pour une caméra) et la nécessité de collecter le fichier vidéo final à partir de nombreux e-mails.

La démo de travail est ici .

Les problèmes rencontrés sont les suivants:

1) Le réseau neuronal charge fortement le processeur et si vous l'exécutez dans le thread principal, des décalages apparaissent sur les vidéos. Par conséquent, la reconnaissance est placée dans un fil distinct (travailleur), bien que tout ne soit pas fluide ici. Sur Linux préhistorique dual-core, tout est parfaitement parallèle, mais sur certains téléphones mobiles 4-core assez récents - au moment de la reconnaissance (chez le travailleur), le thread principal commence également à être en retard, ce qui est perceptible dans l'interface utilisateur. Heureusement, cela n'affecte pas la qualité de la vidéo, même si cela réduit la fréquence de reconnaissance (il s'adapte automatiquement à la charge). Ce problème est probablement lié à la façon dont les différentes versions d'Android distribuent les threads par cœur, à la présence de SIMD, aux fonctions de carte vidéo disponibles, etc. Je ne peux pas le découvrir par moi-même, je ne connais pas l'intérieur de TensorFlow, et je serai reconnaissant pour l'information.

2) FireFox. L'application fonctionne bien sous Chrome / Chrome / Edge, cependant, la reconnaissance dans FireFox est sensiblement plus lente, en outre, ImageCapture n'est toujours pas implémenté (bien sûr, cela peut être contourné en capturant un cadre à partir de <video>, mais c'est une honte pour le renard, car il est standard API). En général, il n'y avait pas non plus d'accessibilité complète entre les navigateurs.

Donc, tout est en ordre.

Obtenir une caméra et un microphone


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

Ici, nous sélectionnons la caméra principale du téléphone mobile / tablette (ou la première sur l'ordinateur / ordinateur portable), affichons le flux dans un lecteur vidéo standard, après quoi nous attendons le chargement des métadonnées et définissons les dimensions du canevas de service. Étant donné que l'application entière est écrite dans le style asynchrone / wait, vous devez convertir les API de rappel (et il y en a beaucoup) en Promise pour l'uniformité.

Capture vidéo


Il existe deux façons de capturer une vidéo. La première consiste à lire directement les images du flux entrant, à les afficher sur le canevas, à les modifier (par exemple, ajouter des zones géographiques et des horodatages), puis à prendre les données du canevas - pour l'enregistreur en tant que flux sortant et pour un réseau de neurones en tant qu'images distinctes. Dans ce cas, vous pouvez vous passer de l'élément <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 deuxième façon (en FF) consiste à utiliser un lecteur vidéo standard pour capturer. Soit dit en passant, il consomme moins de temps processeur, contrairement à l'affichage image par image sur toile, mais nous ne pouvons pas ajouter d'inscription.

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

L'application utilise la première option, ce qui permet d'éteindre le lecteur vidéo pendant le processus de reconnaissance. Afin d'économiser le processeur, l'enregistrement est effectué à partir du flux entrant, et les trames de dessin sur toile sont utilisées uniquement pour obtenir un tableau de pixels pour le réseau neuronal, avec une fréquence dépendant de la vitesse de reconnaissance. Nous dessinons le cadre autour de la personne sur une toile séparée placée sur le joueur.

Chargement du réseau neuronal et détection humaine


C'est indécemment simple. Nous démarrons le travailleur , après avoir chargé le modèle (assez long), nous envoyons un message vide au thread principal, où dans l'événement onmessage nous montrons le bouton de démarrage, après quoi le travailleur est prêt à recevoir des images. Code de travailleur complet:

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

Dans le thread principal, nous démarrons la fonction grab_video () uniquement après avoir reçu le résultat précédent du travailleur, c'est-à-dire que la fréquence de détection dépendra de la charge du système.

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

À chaque arrêt de l'enregistreur (nous utilisons un intervalle fixe), l'événement ondataavailable est déclenché, où le fragment enregistré au format Blob est transféré, enregistré dans this.chunk et envoyé de manière asynchrone. Oui, this.send_chunk () renvoie une promesse, mais la fonction prend beaucoup de temps (encodage en Base64, envoi d'un e-mail ou enregistrement du fichier localement), et nous n'attendons pas qu'il soit exécuté et ne traitons pas le résultat - il n'y a donc pas d'attente. Même s'il s'avère que les nouveaux clips vidéo apparaissent plus souvent qu'ils ne peuvent être envoyés, le moteur JS organise la ligne de promesses de manière transparente pour le développeur, et tôt ou tard toutes les données seront envoyées / enregistrées. La seule chose à laquelle il faut faire attention est à l'intérieur de la fonction send_chunk () avant la première attente, vous devez cloner le Blob avec la méthode slice (), car le lien this.chunk est effacé toutes les CHUNK_DURATION secondes.

API Gmail


Utilisé pour envoyer des lettres. L'API est assez ancienne, en partie sur les promesses, en partie sur les rappels, la documentation et les exemples ne sont pas nombreux, donc je vais donner le code complet.

Autorisation . nous obtenons les clés d'application et de client dans la console développeur de Google. Dans une fenêtre d'autorisation contextuelle, Google signale que l'application n'a pas été vérifiée, et vous devrez cliquer sur "paramètres avancés" pour entrer. La vérification de l'application dans Google s'est avérée être une tâche non triviale, vous devez confirmer la propriété du domaine (que je n'ai pas), organiser correctement la page principale, j'ai donc décidé de ne pas déranger.

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

Envoi d'email . Les chaînes codées en Base64 ne peuvent pas être concaténées, ce qui n'est pas pratique. Comment envoyer une vidéo au format binaire, je ne comprenais toujours pas. Dans les dernières lignes, nous convertissons le rappel en promesse. Malheureusement, cela doit être fait assez souvent.

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

Enregistrement d'un clip vidéo sur le disque. Nous utilisons un hyperlien caché.

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

Gestion des états dans le monde des composants web


Poursuivant l'idée présentée dans cet article , je l'ai amenée à l' absurdité de la fin logique (pour le lulz uniquement) et j'ai bouleversé le contrôle de l'État. Si généralement les variables JS sont considérées comme un état et que le DOM n'est que l'affichage actuel, dans mon cas, la source de données est le DOM lui-même (puisque les composants Web sont les nœuds DOM à longue durée de vie), et pour utiliser les données du côté JS, les composants Web fournissent des getters / setters pour chaque champ de formulaire. Ainsi, par exemple, au lieu de cases à cocher inconfortables dans le style, de simples <button> sont utilisés, et la "valeur" du bouton (true est pressée, false est pressée) est la valeur de l'attribut class, ce qui vous permet de le styler comme ceci:

button.true {background-color: red}

et obtenez la valeur comme ceci:

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

Je ne peux pas conseiller d'utiliser cela en production, car c'est un bon moyen d'abandonner la productivité. Bien que ... le DOM virtuel ne soit pas non plus gratuit, et je n'ai pas fait de benchmarks.

Mode hors-ligne


Enfin, ajoutez un petit PWA, à savoir, installez un service worker qui mettra en cache toutes les requêtes réseau et permettra à l'application de fonctionner sans accès à Internet. Une petite nuance - dans les articles sur les travailleurs des services, ils donnent généralement l'algorithme suivant:

  • Dans l'événement d'installation - créez une nouvelle version du cache et ajoutez toutes les ressources nécessaires au cache.
  • Dans l'événement activate - supprimez toutes les versions du cache à l'exception de la version actuelle.
  • Dans l'événement fetch - nous essayons d'abord de prendre la ressource du cache, et si nous ne l'avons pas trouvée, nous envoyons une requête réseau, dont le résultat est ajouté au cache.

En pratique, un tel schéma n'est pas pratique pour deux raisons. Premièrement, dans le code du travailleur, vous devez avoir une liste à jour de toutes les ressources nécessaires, et dans les grands projets utilisant des bibliothèques tierces, essayez de garder une trace de toutes les importations jointes (y compris les importations dynamiques). Le deuxième problème - lors de la modification d'un fichier, vous devez augmenter la version du service worker, ce qui entraînera l'installation d'un nouveau travailleur et l'invalidation du précédent, et cela se produira UNIQUEMENT lorsque le navigateur est fermé / ouvert. Un simple rafraîchissement de la page n'aidera pas - l'ancien travailleur avec l'ancien cache fonctionnera. Et où est la garantie que mes clients ne garderont pas l'onglet du navigateur pour toujours? Par conséquent, nous faisons d'abord une demande de réseau, nous ajoutons le résultat au cache de manière asynchrone (sans attendre la résolution d'autorisation cache.put (ev.request, resp.clone ())), et si le réseau n'est pas disponible, nous l'obtenons du cache. Mieux vaut perdre un jourpuis volez en 5 minutes ©.

Les questions non résolues


  1. Sur certains téléphones mobiles, le réseau neuronal ralentit, peut-être que dans mon cas, le COCO-SSD n'est pas le meilleur choix, mais je ne suis pas un expert en ML, et j'ai pris le premier qui a été entendu.
  2. Je n'ai pas trouvé d'exemple sur la façon d'envoyer de la vidéo via GAPI non pas au format Base64, mais dans le binaire d'origine. Cela permettrait d'économiser du temps processeur et du trafic réseau.
  3. Je ne comprenais pas la sécurité. À des fins de débogage local, j'ai ajouté le domaine localhost à l'application Google, mais si quelqu'un commence à utiliser les clés de l'application pour envoyer du spam - Google bloquera-t-il les clés elles-mêmes ou le compte de l'expéditeur?

Je serais reconnaissant pour la rétroaction.

Sources sur github.

Merci pour l'attention.

All Articles