Die Leistung von PWA: Ein Videoüberwachungssystem mit einem JS-Code für ein neuronales Netzwerk mit 300 Leitungen

Hallo Habr!

Webbrowser implementieren langsam, aber sicher die meisten Funktionen des Betriebssystems, und es gibt immer weniger Gründe, eine native Anwendung zu entwickeln, wenn Sie eine Webversion (PWA) schreiben können. Plattformübergreifende, umfangreiche API, hohe Entwicklungsgeschwindigkeit auf TS / JS und sogar die Leistung der V8-Engine - all dies ist ein Plus. Browser sind seit langem in der Lage, mit einem Videostream zu arbeiten und neuronale Netze zu betreiben. Das heißt, wir verfügen über alle Komponenten zur Erstellung eines Videoüberwachungssystems mit Objekterkennung. Inspiriert von diesem Artikel habe ich beschlossen, die Demo auf das Niveau der praktischen Anwendung zu bringen, das ich teilen möchte.

Die Anwendung zeichnet Videos von der Kamera auf und sendet regelmäßig Bilder zur Erkennung in der COCO-SSDWenn eine Person erkannt wird, werden Videofragmente in Teilen von 7 Sekunden über die Google Mail-API an die angegebene E-Mail gesendet. Wie in erwachsenen Systemen wird eine Voraufzeichnung durchgeführt, dh wir speichern ein Fragment bis zum Zeitpunkt der Erkennung, alle Fragmente mit Erkennung und eines danach. Wenn das Internet nicht verfügbar ist oder beim Senden ein Fehler auftritt, werden die Videos im lokalen Download-Ordner gespeichert. Wenn Sie die E-Mail verwenden, können Sie auf die Serverseite verzichten, den Eigentümer sofort benachrichtigen. Wenn ein Angreifer das Gerät in Besitz genommen und alle Kennwörter geknackt hat, kann er keine E-Mails vom Empfänger löschen. Von den Minuspunkten - Datenverkehr aufgrund von Base64 (obwohl dies für eine Kamera ausreicht) und die Notwendigkeit, die endgültige Videodatei aus vielen E-Mails zu sammeln.

Die Arbeitsdemo ist da .

Folgende Probleme treten auf:

1) Das neuronale Netzwerk lädt den Prozessor stark, und wenn Sie ihn im Hauptthread ausführen, werden in den Videos Verzögerungen angezeigt. Daher wird die Erkennung in einem separaten Thread (Worker) platziert, obwohl hier nicht alles glatt ist. Unter prähistorischem Dual-Core-Linux ist alles perfekt parallel, aber bei einigen ziemlich neuen 4-Core-Mobiltelefonen beginnt der Haupt-Thread zum Zeitpunkt der Erkennung (im Worker) ebenfalls zu verzögern, was sich in der Benutzeroberfläche bemerkbar macht. Glücklicherweise wirkt sich dies nicht auf die Qualität des Videos aus, obwohl es die Erkennungsfrequenz verringert (es passt sich automatisch an die Last an). Dieses Problem hängt wahrscheinlich damit zusammen, wie verschiedene Versionen von Android Threads auf Kernel verteilen, ob SIMD vorhanden ist, welche Grafikkartenfunktionen verfügbar sind usw. Ich kann es nicht alleine herausfinden, ich kenne die Innenseiten von TensorFlow nicht und werde für die Informationen dankbar sein.

2) FireFox. Die Anwendung funktioniert unter Chrome / Chromium / Edge einwandfrei. Die Erkennung in FireFox ist jedoch spürbar langsamer. Außerdem ist ImageCapture immer noch nicht implementiert (dies kann natürlich umgangen werden, indem ein Frame aus <video> aufgenommen wird, aber es ist eine Schande für den Fuchs, da dies Standard ist API). Im Allgemeinen gab es auch keine vollständige browserübergreifende Zugänglichkeit.

Also alles in Ordnung.

Kamera und Mikrofon besorgen


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

Hier wählen wir die Hauptkamera des Mobiltelefons / Tablets (oder die erste auf dem Computer / Laptop) aus, zeigen den Stream in einem Standard-Videoplayer an. Danach warten wir, bis die Metadaten geladen sind, und legen die Abmessungen des Servicebereichs fest. Da die gesamte Anwendung im Stil von async / await geschrieben ist, müssen Sie Callback-APIs (und es gibt ziemlich viele davon) in Promise for Uniformity konvertieren.

Videoaufnahme


Es gibt zwei Möglichkeiten, Videos aufzunehmen. Die erste besteht darin, die Frames direkt aus dem eingehenden Stream zu lesen, sie auf der Zeichenfläche anzuzeigen, zu ändern (z. B. Geo- und Zeitstempel hinzuzufügen) und dann die Daten aus der Zeichenfläche zu übernehmen - für den Rekorder als ausgehenden Stream und für ein neuronales Netzwerk als separate Bilder. In diesem Fall können Sie auf das <video> -Element verzichten.

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

Die zweite Möglichkeit (Arbeiten in FF) besteht darin, einen Standard-Videoplayer zum Aufnehmen zu verwenden. Im Gegensatz zur Einzelbildanzeige auf Leinwand benötigt es übrigens weniger Prozessorzeit, aber wir können keine Beschriftung hinzufügen.

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

Die Anwendung verwendet die erste Option, wodurch der Videoplayer während des Erkennungsprozesses ausgeschaltet werden kann. Um den Prozessor zu sparen, wird die Aufzeichnung vom eingehenden Stream ausgeführt, und das Zeichnen von Frames auf Leinwand wird nur verwendet, um ein Array von Pixeln für das neuronale Netzwerk mit einer Frequenz zu erhalten, die von der Erkennungsgeschwindigkeit abhängt. Wir zeichnen den Rahmen um die Person auf einer separaten Leinwand, die auf dem Player platziert ist.

Laden neuronaler Netze und Erkennung von Menschen


Es ist alles unanständig einfach. Wir starten den Worker , nachdem wir das Modell (für eine ziemlich lange Zeit) geladen haben, senden wir eine leere Nachricht an den Haupt-Thread, wo wir im Ereignis onmessage die Startschaltfläche anzeigen, wonach der Worker bereit ist, Bilder zu empfangen. Vollständiger Worker-Code:

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

Im Hauptthread starten wir die Funktion grab_video () erst, nachdem wir das vorherige Ergebnis vom Worker erhalten haben, dh die Erkennungshäufigkeit hängt von der Systemlast ab.

Videoaufnahme


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)

Bei jedem Stopp des Rekorders (wir verwenden ein festes Intervall) wird das Ereignis ondataavailable ausgelöst, bei dem das aufgezeichnete Fragment im Blob-Format übertragen, in this.chunk gespeichert und asynchron gesendet wird. Ja, this.send_chunk () gibt ein Versprechen zurück, aber die Funktion dauert lange (Codierung in Base64, Senden einer E-Mail oder lokales Speichern der Datei), und wir warten nicht auf die Ausführung und verarbeiten das Ergebnis nicht - daher gibt es keine Wartezeit. Selbst wenn sich herausstellt, dass neue Videoclips häufiger erscheinen als gesendet werden können, ordnet die JS-Engine die Versprechungsreihe für den Entwickler transparent an, und alle Daten werden früher oder später gesendet / geschrieben. Das einzige, worauf Sie achten sollten, ist die Funktion send_chunk () Vor dem ersten Warten müssen Sie den Blob mit der Slice () -Methode klonen, da der this.chunk-Link alle CHUNK_DURATION-Sekunden gerieben wird.

Google Mail-API


Wird zum Versenden von Briefen verwendet. Die API ist ziemlich alt, teils aufgrund von Versprechungen, teils aufgrund von Rückrufen. Dokumentation und Beispiele sind nicht reichlich vorhanden, daher werde ich den vollständigen Code angeben.

Autorisierung . Wir erhalten die Anwendungs- und Client-Schlüssel in der Google-Entwicklerkonsole. In einem Popup-Autorisierungsfenster meldet Google, dass die Anwendung nicht überprüft wurde, und Sie müssen auf "Erweiterte Einstellungen" klicken, um sie einzugeben. Das Überprüfen der Anwendung in Google stellte sich als nicht triviale Aufgabe heraus. Sie müssen den Besitz der Domain (die ich nicht habe) bestätigen und die Hauptseite korrekt anordnen, damit ich mich nicht darum kümmere.

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

E-Mail senden . Base64-codierte Zeichenfolgen können nicht verkettet werden, was unpraktisch ist. Wie man Videos im Binärformat sendet, habe ich immer noch nicht verstanden. In den letzten Zeilen wandeln wir den Rückruf in ein Versprechen um. Leider muss dies ziemlich oft gemacht werden.

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

Speichern eines Videoclips auf der Festplatte. Wir verwenden einen versteckten Hyperlink.

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

State Management in der Welt der Webkomponenten


Ich setzte die in diesem Artikel vorgestellte Idee fort , brachte sie zur Absurdität des logischen Endes (nur für den Lulz) und stellte die Kontrolle über den Staat auf den Kopf. Wenn normalerweise JS-Variablen als Status betrachtet werden und das DOM nur die aktuelle Anzeige ist, ist in meinem Fall die Datenquelle das DOM selbst (da Webkomponenten die langlebigen DOM-Knoten sind), und für die Verwendung von Daten auf der JS-Seite stellen die Webkomponenten Getter / bereit Setter für jedes Formularfeld. So werden beispielsweise anstelle unangenehmer Kontrollkästchen beim Stylen einfache <Button> verwendet, und der „Wert“ der Schaltfläche (true wird gedrückt, false wird gedrückt) ist der Wert des Klassenattributs, mit dem Sie es wie folgt formatieren können:

button.true {background-color: red}

und erhalten Sie den Wert wie folgt:

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

Ich kann nicht raten, dies in der Produktion zu verwenden, da dies ein guter Weg ist, um die Produktivität zu senken. Obwohl ... das virtuelle DOM auch nicht kostenlos ist und ich keine Benchmarks durchgeführt habe.

Offline-Modus


Fügen Sie abschließend eine kleine PWA hinzu, und installieren Sie einen Servicemitarbeiter, der alle Netzwerkanforderungen zwischenspeichert und der Anwendung ermöglicht, ohne Internetzugang zu arbeiten. Eine kleine Nuance - in Artikeln über Servicemitarbeiter geben sie normalerweise den folgenden Algorithmus an:

  • Erstellen Sie im Installationsereignis eine neue Version des Caches und fügen Sie dem Cache alle erforderlichen Ressourcen hinzu.
  • Löschen Sie im Aktivierungsereignis alle Versionen des Caches mit Ausnahme der aktuellen.
  • Im Abrufereignis versuchen wir zunächst, die Ressource aus dem Cache zu entnehmen. Wenn wir sie nicht gefunden haben, senden wir eine Netzwerkanforderung, deren Ergebnis dem Cache hinzugefügt wird.

In der Praxis ist ein solches Schema aus zwei Gründen unpraktisch. Erstens müssen Sie im Worker-Code eine aktuelle Liste aller erforderlichen Ressourcen haben und in großen Projekten, die Bibliotheken von Drittanbietern verwenden, versuchen, alle angehängten Importe (einschließlich dynamischer) zu verfolgen. Das zweite Problem: Wenn Sie eine Datei ändern, müssen Sie die Version des Service Workers erhöhen, was zur Installation eines neuen Workers und zur Ungültigmachung des vorherigen führt. Dies geschieht NUR, wenn der Browser geschlossen / geöffnet wird. Eine einfache Seitenaktualisierung hilft nicht - der alte Mitarbeiter mit dem alten Cache funktioniert. Und wo ist die Garantie, dass meine Kunden den Browser-Tab nicht für immer behalten? Daher fügen wir zuerst eine Netzwerkanforderung hinzu und fügen das Ergebnis asynchron zum Cache hinzu (ohne auf die Berechtigungsauflösung cache.put (ev.request, resp.clone ()) zu warten). Wenn das Netzwerk nicht verfügbar ist, erhalten wir es aus dem Cache. Besser einen Tag verlierendann in 5 Minuten fliegen ©.

Ungelöste Probleme


  1. Bei einigen Mobiltelefonen verlangsamt sich das neuronale Netzwerk. In meinem Fall ist COCO-SSD möglicherweise nicht die beste Wahl, aber ich bin kein ML-Experte, und ich habe die erste gewählt, die gehört wurde.
  2. Ich habe kein Beispiel gefunden, wie man Videos über GAPI nicht im Base64-Format, sondern in der Original-Binärdatei sendet. Dies würde sowohl Prozessorzeit als auch Netzwerkverkehr sparen.
  3. Ich habe die Sicherheit nicht verstanden. Für lokale Debugging-Zwecke habe ich die localhost-Domain zur Google-Anwendung hinzugefügt. Wenn jedoch jemand die Anwendungsschlüssel zum Senden von Spam verwendet, blockiert Google die Schlüssel selbst oder das Konto des Absenders?

Für das Feedback wäre ich dankbar.

Quellen zu Github.

Vielen Dank für Ihre Aufmerksamkeit.

All Articles