Kekuatan PWA: Sistem pengawasan video dengan kode JS 300-neural network neural

Halo, Habr!

Browser web perlahan tapi pasti mengimplementasikan sebagian besar fitur sistem operasi, dan semakin sedikit alasan untuk mengembangkan aplikasi asli jika Anda dapat menulis versi web (PWA). Cross-platform, API yang kaya, kecepatan pengembangan tinggi pada TS / JS, dan bahkan kinerja mesin V8 - semua ini merupakan nilai tambah. Browser telah lama dapat bekerja dengan aliran video dan menjalankan jaringan saraf, yaitu, kita memiliki semua komponen untuk membuat sistem pengawasan video dengan pengenalan objek. Terinspirasi oleh artikel ini , saya memutuskan untuk membawa demo ke tingkat aplikasi praktis, yang ingin saya bagikan.

Aplikasi merekam video dari kamera, secara berkala mengirimkan bingkai untuk pengakuan dalam COCO-SSD, dan jika seseorang terdeteksi, fragmen video dalam porsi 7 detik mulai dikirim ke email yang ditentukan melalui Gmail-API. Seperti dalam sistem dewasa, rekaman dilakukan, yaitu, kami menyimpan satu fragmen hingga saat deteksi, semua fragmen dengan deteksi, dan satu demi satu. Jika Internet tidak tersedia, atau terjadi kesalahan saat mengirim, video disimpan di folder Unduhan lokal. Menggunakan surel memungkinkan Anda melakukannya tanpa sisi server, langsung memberi tahu pemilik, dan jika penyerang menguasai perangkat dan meretas semua kata sandi, ia tidak akan dapat menghapus surel dari penerima. Dari minus - lalu lintas overrun karena Base64 (meskipun cukup untuk satu kamera), dan kebutuhan untuk mengumpulkan file video final dari banyak email.

Demo kerja ada di sini .

Masalah yang dihadapi adalah sebagai berikut:

1) Jaringan saraf memuat prosesor, dan jika Anda menjalankannya di utas utama, kelambatan muncul di video. Oleh karena itu, pengakuan ditempatkan di utas terpisah (pekerja), meskipun tidak semuanya lancar di sini. Semuanya paralel sempurna pada Linux prasejarah dual-core, tetapi pada beberapa ponsel 4-core yang cukup baru - pada saat pengakuan (pada pekerja), utas utama juga mulai tertinggal, yang terlihat pada antarmuka pengguna. Untungnya, ini tidak mempengaruhi kualitas video, meskipun mengurangi frekuensi pengenalan (secara otomatis menyesuaikan dengan beban). Masalah ini mungkin terkait dengan bagaimana berbagai versi Android mendistribusikan utas demi inti, kehadiran SIMD, fungsi kartu video yang tersedia, dll. Saya tidak bisa mengetahuinya sendiri, saya tidak tahu bagian dalam TensorFlow, dan saya akan berterima kasih atas informasinya.

2) FireFox. Aplikasi ini berfungsi dengan baik di bawah Chrome / Chromium / Edge, namun, pengakuan di FireFox terasa lebih lambat, di samping itu, ImageCapture belum diimplementasikan (tentu saja, ini dapat dilewati dengan mengambil bingkai dari <video>, tapi bagaimanapun itu memalukan bagi rubah, karena itu standar untuk rubah, karena standar API). Secara umum, tidak ada aksesibilitas lintas-browser yang lengkap.

Jadi, semuanya beres.

Dapatkan kamera dan mikrofon


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

Di sini kita memilih kamera utama ponsel / tablet (atau yang pertama di komputer / laptop), menampilkan aliran dalam pemutar video standar, setelah itu kita menunggu metadata untuk memuat dan mengatur dimensi kanvas layanan. Karena seluruh aplikasi ditulis dengan gaya async / menunggu, Anda harus mengonversi API panggil balik (dan jumlahnya cukup banyak) menjadi Janji untuk keseragaman.

Pengambilan video


Ada dua cara untuk merekam video. Yang pertama adalah untuk langsung membaca frame dari aliran masuk, menampilkannya di kanvas, memodifikasinya (misalnya, menambahkan geo dan cap waktu), dan kemudian mengambil data dari kanvas - untuk perekam sebagai aliran keluar, dan untuk jaringan saraf sebagai gambar terpisah. Dalam hal ini, Anda dapat melakukannya tanpa elemen <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))
}

Cara kedua (bekerja di FF) adalah menggunakan pemutar video standar untuk menangkap. Omong-omong, ini menghabiskan waktu prosesor lebih sedikit, tidak seperti tampilan frame-by-frame di kanvas, tetapi kami tidak dapat menambahkan tulisan.

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

Aplikasi menggunakan opsi pertama, sebagai akibatnya pemutar video dapat dimatikan selama proses pengenalan. Untuk menghemat prosesor, perekaman dilakukan dari aliran masuk, dan menggambar bingkai di atas kanvas hanya digunakan untuk memperoleh array piksel untuk jaringan saraf, dengan frekuensi tergantung pada kecepatan pengenalan. Kami menggambar bingkai di sekitar orang tersebut pada kanvas terpisah yang ditempatkan pada pemain.

Pemuatan jaringan saraf dan deteksi manusia


Itu semua tidak senonoh sederhana. Kami mulai pekerja , setelah memuat model (agak lama), kami mengirim pesan kosong ke utas utama, di mana pada acara onmessage kami menunjukkan tombol mulai, setelah itu pekerja siap menerima gambar. Kode pekerja penuh:

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

Di utas utama, kami memulai fungsi grab_video () hanya setelah menerima hasil sebelumnya dari pekerja, yaitu, frekuensi deteksi akan tergantung pada beban sistem.

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

Pada setiap pemberhentian perekam (kami menggunakan interval tetap), peristiwa yang tersedia tersedia dinaikkan, di mana fragmen yang direkam dalam format Blob ditransfer, disimpan di this.chunk dan dikirim secara tidak sinkron. Ya, this.send_chunk () mengembalikan janji, tetapi fungsinya membutuhkan waktu lama (penyandian di Base64, mengirim email atau menyimpan file secara lokal), dan kami tidak menunggu sampai dieksekusi dan tidak memproses hasilnya - karena itu tidak ada menunggu. Bahkan jika ternyata klip video baru muncul lebih sering daripada yang dapat dikirim, mesin JS mengatur garis janji secara transparan untuk pengembang, dan cepat atau lambat semua data akan dikirim / direkam. Satu-satunya hal yang perlu diperhatikan adalah di dalam fungsi send_chunk () sebelum yang pertama menunggu, Anda perlu mengkloning Blob dengan metode slice (), karena tautan this.chunk di-gosok setiap CHUNK_DURATION detik.

API Gmail


Digunakan untuk mengirim surat. API sudah cukup tua, sebagian karena janji, sebagian pada panggilan balik, dokumentasi dan contoh tidak banyak, jadi saya akan memberikan kode lengkap.

Otorisasi . kami mendapatkan kunci aplikasi dan klien di konsol pengembang Google. Di jendela otorisasi pop-up, Google melaporkan bahwa aplikasi tersebut belum diverifikasi, dan Anda harus mengklik "pengaturan lanjutan" untuk masuk. Memeriksa aplikasi di Google ternyata menjadi tugas yang tidak sepele, Anda perlu mengkonfirmasi kepemilikan domain (yang saya tidak punya), mengatur halaman utama dengan benar, jadi saya memutuskan untuk tidak repot-repot.

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

Pengiriman email . String yang disandikan Base64 tidak dapat digabungkan, dan ini tidak nyaman. Cara mengirim video dalam format biner, saya masih tidak mengerti. Di baris terakhir, kami mengonversi panggilan balik ke janji. Sayangnya ini harus dilakukan cukup sering.

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

Menyimpan klip video ke disk. Kami menggunakan hyperlink tersembunyi.

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

Manajemen negara dalam dunia komponen web


Melanjutkan ide yang disajikan dalam artikel ini , saya membawanya ke absurditas dari akhir logis (hanya untuk lulz) dan membalikkan kontrol negara terbalik. Jika biasanya variabel JS dianggap sebagai keadaan, dan DOM hanya tampilan saat ini, maka dalam kasus saya sumber data adalah DOM itu sendiri (karena komponen web adalah node DOM yang berumur panjang), dan untuk menggunakan data di sisi JS, komponen web menyediakan getter / setter untuk setiap bidang formulir. Jadi, misalnya, alih-alih kotak centang yang tidak nyaman dalam gaya, <button> sederhana digunakan, dan "nilai" tombol (true ditekan, false ditekan) adalah nilai atribut kelas, yang memungkinkan Anda untuk menatanya seperti ini:

button.true {background-color: red}

dan dapatkan nilai seperti ini:

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

Saya tidak bisa menyarankan menggunakan ini dalam produksi, karena ini adalah cara yang baik untuk membuang produktivitas. Meskipun ... DOM virtual juga tidak gratis, dan saya tidak melakukan benchmark.

Mode offline


Terakhir, tambahkan sedikit PWA, yaitu, instal pekerja layanan yang akan melakukan cache semua permintaan jaringan dan memungkinkan aplikasi bekerja tanpa akses ke Internet. Nuansa kecil - dalam artikel tentang pekerja layanan, mereka biasanya memberikan algoritma berikut:

  • Dalam acara pemasangan - buat versi cache yang baru dan tambahkan semua sumber daya yang diperlukan ke cache.
  • Dalam acara aktivasi - hapus semua versi cache kecuali yang sekarang.
  • Dalam acara ambil - pertama-tama kami mencoba mengambil sumber dari cache, dan jika kami tidak menemukannya, kami mengirim permintaan jaringan, yang hasilnya ditambahkan ke cache.

Dalam praktiknya, skema semacam itu tidak nyaman karena dua alasan. Pertama, dalam kode pekerja Anda perlu memiliki daftar terbaru dari semua sumber daya yang diperlukan, dan dalam proyek-proyek besar menggunakan perpustakaan pihak ketiga, cobalah untuk melacak semua impor yang dilampirkan (termasuk yang dinamis). Masalah kedua - ketika mengubah file apa pun, Anda perlu meningkatkan versi pekerja layanan, yang akan mengarah pada pemasangan pekerja baru dan pembatalan yang sebelumnya, dan ini akan terjadi HANYA ketika browser ditutup / dibuka. Refresh halaman sederhana tidak akan membantu - pekerja lama dengan cache lama akan bekerja. Dan di mana jaminan bahwa klien saya tidak akan menjaga tab browser selamanya? Oleh karena itu, pertama kita membuat permintaan jaringan, kita menambahkan hasilnya ke cache secara tidak sinkron (tanpa menunggu resolusi izin cache.put (ev.request, resp.clone ())), dan jika jaringan tidak tersedia, maka kita mendapatkannya dari cache. Lebih baik kehilangan seharikemudian terbang dalam 5 menit ยฉ.

Masalah yang belum terselesaikan


  1. Pada beberapa ponsel, jaringan saraf melambat, mungkin dalam kasus saya, COCO-SSD bukan pilihan terbaik, tapi saya bukan ahli ML, dan saya mengambil yang pertama yang didengar.
  2. Saya tidak menemukan contoh cara mengirim video melalui GAPI bukan dalam format Base64, tetapi dalam biner asli. Ini akan menghemat waktu prosesor dan lalu lintas jaringan.
  3. Saya tidak mengerti keamanan. Untuk keperluan debugging lokal, saya menambahkan domain localhost ke aplikasi Google, tetapi jika seseorang mulai menggunakan kunci aplikasi untuk mengirim spam - apakah Google akan memblokir kunci sendiri atau akun pengirim?

Saya akan berterima kasih atas umpan baliknya.

Sumber di github.

Terimakasih atas perhatiannya.

All Articles