قوة PWA: نظام للمراقبة بالفيديو مع رمز JS للشبكة العصبية بسعة 300 سطر

مرحبا يا هابر! تطبق

متصفحات الويب ببطء ولكن بثبات معظم ميزات نظام التشغيل ، وهناك سبب أقل وأقل لتطوير تطبيق أصلي إذا كان يمكنك كتابة إصدار ويب (PWA). عبر الأنظمة الأساسية ، واجهة برمجة تطبيقات غنية ، سرعة تطوير عالية على TS / JS ، وحتى أداء محرك V8 - كل هذا زائد. تمكنت المتصفحات منذ فترة طويلة من العمل مع دفق الفيديو وتشغيل الشبكات العصبية ، أي لدينا جميع المكونات لإنشاء نظام مراقبة فيديو مع التعرف على الكائنات. مستوحاة من هذه المقالة ، قررت رفع العرض التوضيحي إلى مستوى التطبيق العملي ، الذي أريد مشاركته. يسجل التطبيق فيديو من الكاميرا ، ويرسل إطارات بشكل دوري للتعرف عليها في COCO-SSD

، وإذا تم الكشف عن شخص ما ، يبدأ إرسال أجزاء الفيديو في أجزاء من 7 ثوانٍ إلى البريد الإلكتروني المحدد عبر Gmail-API. كما هو الحال في أنظمة البالغين ، يتم إجراء التسجيل المسبق ، أي أننا نحفظ جزءًا واحدًا حتى لحظة الكشف ، وجميع الأجزاء مع الكشف ، وأخرى بعدها. إذا كان الإنترنت غير متوفر ، أو حدث خطأ أثناء الإرسال ، يتم حفظ مقاطع الفيديو في مجلد التنزيلات المحلي. يتيح لك استخدام البريد الإلكتروني الاستغناء عن جانب الخادم ، وإخطار المالك على الفور ، وإذا استولى مهاجم على الجهاز وقام بكسر جميع كلمات المرور ، فلن يتمكن من حذف البريد من المستلم. من السلبيات - تجاوز حركة المرور بسبب Base64 (على الرغم من أنها كافية لكاميرا واحدة) ، والحاجة إلى جمع ملف الفيديو النهائي من العديد من رسائل البريد الإلكتروني.

عرض العمل هنا .

المشاكل التي تمت مواجهتها هي كما يلي:

1) تقوم الشبكة العصبية بتحميل المعالج بشكل كبير ، وإذا قمت بتشغيله في الخيط الرئيسي ، تظهر تأخيرات على مقاطع الفيديو. لذلك ، يتم وضع الاعتراف في خيط منفصل (عامل) ، على الرغم من أنه ليس كل شيء سلسًا هنا. في لينكس ما قبل التاريخ ثنائي النواة ، كل شيء متوازي تمامًا ، ولكن على بعض الهواتف المحمولة الجديدة رباعية النوى إلى حد ما - في لحظة الاعتراف (في العامل) ، يبدأ الخيط الرئيسي أيضًا في التأخير ، وهو أمر ملحوظ في واجهة المستخدم. لحسن الحظ ، هذا لا يؤثر على جودة الفيديو ، على الرغم من أنه يقلل من تردد التعرف (يتم ضبطه تلقائيًا على الحمل). ربما تتعلق هذه المشكلة بكيفية توزيع الإصدارات المختلفة من Android لمؤشرات الترابط حسب النواة ووجود بطاقة SIMD ووظائف بطاقة الفيديو المتاحة وما إلى ذلك. لا يمكنني معرفة ذلك بمفردي ، ولا أعرف ما في الداخل من TensorFlow ، وسأكون ممتنًا للمعلومات.

2) فايرفوكس. يعمل التطبيق بشكل جيد تحت 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 / ينتظر ، يجب عليك تحويل واجهات برمجة تطبيقات رد الاتصال (وهناك الكثير منها) إلى وعد بالتوحيد.

التقاط الفيديو


هناك طريقتان لالتقاط الفيديو. الأول هو قراءة الإطارات مباشرة من الدفق الوارد وعرضها على اللوحة القماشية وتعديلها (على سبيل المثال ، إضافة geo والطوابع الزمنية) ، ثم أخذ البيانات من اللوحة القماشية - للمسجل كتدفق صادر ، وللشبكة العصبية كصور منفصلة. في هذه الحالة ، يمكنك الاستغناء عن عنصر <الفيديو>.

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.send_chunk () يُرجع وعدًا ، ولكن الوظيفة تستغرق وقتًا طويلاً (الترميز في Base64 أو إرسال بريد إلكتروني أو حفظ الملف محليًا) ، ونحن لا ننتظر تنفيذه ولا نعالج النتيجة - وبالتالي لا يوجد انتظار. حتى إذا اتضح أن مقاطع الفيديو الجديدة تظهر في كثير من الأحيان أكثر مما يمكن إرسالها ، فإن محرك JS يرتب خط الوعود بشفافية للمطور ، وسيتم إرسال / تسجيل جميع البيانات عاجلاً أم آجلاً. الشيء الوحيد الذي يستحق الانتباه إليه هو داخل دالة send_chunk () قبل الانتظار الأول ، تحتاج إلى استنساخ Blob بطريقة slice () ، حيث يتم فرك الرابط this.chunk كل CHUNK_DURATION ثانية.

واجهة برمجة تطبيقات Gmail


تستخدم لإرسال الرسائل. واجهة برمجة التطبيقات (API) قديمة جدًا ، جزئياً بوعود ، وجزئياً على الاسترجاعات ، والوثائق والأمثلة ليست وفيرة ، لذلك سأقدم الشفرة الكاملة.

التفويض . نحصل على مفاتيح التطبيق والعميل في وحدة تحكم مطوري Google. في نافذة مصادقة منبثقة ، تفيد Google أن التطبيق لم يتم التحقق منه ، وسيتعين عليك النقر فوق "الإعدادات المتقدمة" للدخول. تحول التحقق من التطبيق في 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()

إدارة الدولة في عالم مكونات الويب


استمرارًا للفكرة المقدمة في هذه المقالة ، عرضتها على عبثية النهاية المنطقية (لـ lulz فقط) وقلبت سيطرة الدولة رأساً على عقب. إذا تم اعتبار متغيرات JS عادةً كحالة ، وكان DOM هو العرض الحالي فقط ، فإن مصدر البيانات في حالتي هو DOM نفسه (نظرًا لأن مكونات الويب هي عقد DOM طويلة العمر) ، وباستخدام البيانات على جانب JS ، فإن مكونات الويب توفر رسائل / تعيين كل حقل النموذج. لذلك ، على سبيل المثال ، بدلاً من مربعات الاختيار غير المريحة في التصميم ، يتم استخدام <button> البسيط ، و "قيمة" الزر (يتم الضغط على true ، يتم الضغط على false) هي قيمة سمة class ، والتي تسمح لك بتصميمها على النحو التالي:

button.true {background-color: red}

واحصل على القيمة مثل هذا:

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

لا يمكنني أن أنصح باستخدام هذا في الإنتاج ، لأن هذه طريقة جيدة للتخلص من الإنتاجية. على الرغم من ... DOM الافتراضي ليس مجانيًا أيضًا ، ولم أقم بالمعايير.

وضع غير متصل بالشبكة


أخيرًا ، أضف القليل من PWA ، أي تثبيت عامل خدمة يقوم بتخزين جميع طلبات الشبكة مؤقتًا ويسمح للتطبيق بالعمل دون الوصول إلى الإنترنت. فارق بسيط - في مقالات عن عمال الخدمة ، عادة ما يعطون الخوارزمية التالية:

  • في حدث التثبيت - أنشئ نسخة جديدة من ذاكرة التخزين المؤقت وأضف جميع الموارد اللازمة إلى ذاكرة التخزين المؤقت.
  • في حدث التفعيل - احذف جميع إصدارات ذاكرة التخزين المؤقت باستثناء النسخة الحالية.
  • في حدث الجلب - نحاول أولاً أخذ المورد من ذاكرة التخزين المؤقت ، وإذا لم نعثر عليه ، نرسل طلب شبكة ، تتم إضافة النتيجة إلى ذاكرة التخزين المؤقت.

من الناحية العملية ، هذا المخطط غير مناسب لسببين. أولاً ، في كود العامل ، يجب أن يكون لديك قائمة محدثة بجميع الموارد اللازمة ، وفي المشاريع الكبيرة التي تستخدم مكتبات تابعة لجهات خارجية ، حاول تتبع جميع عمليات الاستيراد المرفقة (بما في ذلك الموارد الديناميكية). المشكلة الثانية - عند تغيير أي ملف ، تحتاج إلى زيادة إصدار عامل الخدمة ، مما سيؤدي إلى تثبيت عامل جديد وإبطال العامل السابق ، وهذا سيحدث فقط عندما يتم إغلاق / فتح المتصفح. لن يساعد تحديث الصفحة البسيط - سيعمل العامل القديم مع ذاكرة التخزين المؤقت القديمة. وأين هو ضمان ألا يحتفظ عملائي بعلامة تبويب المتصفح إلى الأبد؟ لذلك ، نقوم أولاً بتقديم طلب شبكة ، ونضيف النتيجة إلى ذاكرة التخزين المؤقت بشكل غير متزامن (دون انتظار حل ذاكرة التخزين المؤقت cache.put (ev.request ، resp.clone ())) ، وإذا كانت الشبكة غير متوفرة ، فإننا نحصل عليها من ذاكرة التخزين المؤقت. من الأفضل أن تخسر يومًاثم تطير في 5 دقائق ©.

القضايا التي لم تحل


  1. في بعض الهواتف المحمولة ، تتباطأ الشبكة العصبية ، ربما في حالتي ، فإن COCO-SSD ليس الخيار الأفضل ، لكنني لست خبيرًا في ML ، وأخذت أول واحد تم سماعه.
  2. لم أجد مثالاً على كيفية إرسال الفيديو عبر GAPI ليس بتنسيق Base64 ، ولكن في الثنائي الأصلي. سيوفر ذلك وقت المعالج وحركة مرور الشبكة.
  3. لم أفهم الأمن. لأغراض تصحيح الأخطاء المحلية ، أضفت نطاق localhost إلى تطبيق Google ، ولكن إذا بدأ شخص ما في استخدام مفاتيح التطبيق لإرسال رسائل غير مرغوب فيها - فهل ستحظر Google المفاتيح نفسها أو حساب المرسل؟

سأكون ممتنا لردود الفعل.

مصادر في جيثب.

شكرا للانتباه.

All Articles