PWA的力量:具有300行神经网络JS代码的视频监视系统

哈Ha!

Web浏览器缓慢但确实实现了操作系统的大多数功能,如果您可以编写Web版本(PWA),则开发本机应用程序的理由就越来越少。跨平台,丰富的API,在TS / JS上的高开发速度以及V8引擎的性能都是加号。浏览器长期以来一直能够处理视频流并运行神经网络,也就是说,我们拥有用于创建具有对象识别功能的视频监视系统的所有组件。受本文的启发,我决定将演示带到实际应用中,我想分享一下。

该应用程序记录来自摄像机的视频,并定期发送帧以在COCO-SSD中进行识别,如果检测到人,则视频片段将在7秒内开始通过Gmail-API发送到指定的电子邮件。与成人系统一样,将执行预记录,也就是说,我们保存一个片段直到检测到,所有片段都被检测到,再保存一个。如果Internet不可用,或者发送时发生错误,则视频将保存在本地的Downloads文件夹中。使用电子邮件使您无需服务器端即可完成操作,立即通知所有者,如果攻击者拥有了该设备并破解了所有密码,它将无法从收件人那里删除邮件。缺点-由于Base64而导致流量超支(尽管对于一台摄像机来说已经足够了),并且需要从许多电子邮件中收集最终的视频文件。

工作演示在这里

遇到的问题如下:

1)神经网络给处理器带来了沉重的负担,如果您在主线程中运行它,视频中会出现延迟。因此,尽管这里并不是所有事情都很顺利,但是识别是放在单独的线程(工作人员)中的。在双核史前Linux上,一切都是完全并行的,但是在一些相当新的4核移动电话上-在识别的瞬间(在工作环境中),主线程也开始滞后,这在用户界面中很明显。幸运的是,尽管它降低了识别频率(它会自动适应负载),但不会影响视频质量。此问题可能与不同版本的Android如何按核心分配线程,SIMD的存在,可用的视频卡功能等有关。我自己无法解决问题,我不了解TensorFlow的内幕,我将不胜感激。

2)FireFox。该应用程序在Chrome / Chromium / Edge下可以正常运行,但是,在FireFox中的识别速度明显慢,此外,ImageCapture仍未实现(当然,可以通过捕获<video>的帧来绕过它,但这对于Fox来说很遗憾,因为它是标准的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 / await的方式编写的,因此您必须将回调API(并且有很多)转换为Promise以获得一致性。

视频截取


有两种捕获视频的方法。第一种是直接从传入流中读取帧,将其显示在画布上,对其进行修改(例如,添加地理和时间戳记),然后从画布中获取数据-将记录器作为传出流,将神经网络作为单独的图像。在这种情况下,您可以不使用<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))
}

第二种方法(在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.chunk中并异步发送。是的,this.send_chunk()返回一个promise,但是该函数花费很长时间(在Base64中编码,发送电子邮件或在本地保存文件),我们不等待它执行也不处理结果-因此没有等待。即使事实证明新的视频剪辑出现的次数多于发送的次数,JS引擎也会为开发人员透明地排列承诺的范围,迟早所有数据都将被发送/记录。唯一需要注意的是在send_chunk()函数内部 在第一次等待之前,您需要使用slice()方法克隆Blob,因为this.chunk链接每CHUNK_DURATION秒都会被摩擦一次。

Gmail API


用于发送信件。该API已经很老了,部分是基于Promise,部分是关于回调,文档和示例的,因此该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编码的字符串,这很不方便。如何以二进制格式发送视频,我仍然不明白。在最后几行中,我们将回调转换为Promise。不幸的是,这必须经常进行。

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

Web组件世界中的状态管理


继续本文中提出的想法,我将其带到逻辑端荒谬之处(仅适用于lulz),并将对状态的控制颠倒了。如果通常将JS变量视为状态,并且DOM仅是当前显示,那么在我的情况下,数据源是DOM本身(因为Web组件是寿命很长的DOM节点),并且对于在JS端使用数据,Web组件提供了getters /每个表单字段的设置器。因此,例如,使用简单的<button>代替了样式中令人讨厌的复选框,并且按钮的“ value”(按下true,按下false)是class属性的值,可让您使用以下方式对其进行样式设置:

button.true {background-color: red}

并得到这样的值:

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

我不建议在生产中使用它,因为这是降低生产率的好方法。尽管...虚拟DOM也不免费,我也没有做基准测试。

离线模式


最后,添加一些PWA,即安装服务人员服务人员将缓存所有网络请求,并允许应用程序在不访问Internet的情况下工作。细微差别-在有关服务人员的文章中,他们通常提供以下算法:

  • 在安装事件中-创建高速缓存的新版本,并将所有必需的资源添加到高速缓存中。
  • 在Activate事件中-删除除当前版本以外的所有版本的缓存。
  • 在fetch事件中-首先,我们尝试从缓存中获取资源,如果找不到资源,我们将发送网络请求,并将其结果添加到缓存中。

实际上,由于两个原因,这种方案是不方便的。首先,在工作人员的代码中,您需要具有所有必要资源的最新列表,在使用第三方库的大型项目中,请尝试跟踪所有附加的导入(包括动态导入)。第二个问题-更改任何文件时,您需要增加service worker的版本,这将导致安装新的worker并使先前的worker无效,并且仅在关闭/打开浏览器时才会发生。简单的页面刷新将无济于事-具有旧缓存的旧工作线程将起作用。哪里可以保证我的客户永远不会保留浏览器标签?因此,首先我们发出一个网络请求,我们将结果异步添加到缓存中(无需等待权限解析cache.put(ev.request,resp.clone())),如果网络不可用,则从缓存中获取它。最好输掉一天然后在5分钟内©飞行。

尚未解决的问题


  1. 在某些手机上,神经网络速度变慢,也许就我而言,COCO-SSD不是最佳选择,但我不是ML专家,因此我采用了第一个听到的声音。
  2. 我没有找到一个示例,说明如何通过GAPI发送的视频不是Base64格式,而是原始二进制文件。这将节省处理器时间和网络流量。
  3. 我不了解安全性。为了进行本地调试,我将localhost域添加到了Google应用程序中,但是如果有人开始使用应用程序密钥发送垃圾邮件-Google会自行阻止密钥还是发送者的帐户?

谢谢您的反馈。

github上的资源。

谢谢您的关注。

All Articles