O poder do PWA: um sistema de vigilância por vídeo com um código JS de rede neural de 300 linhas

Olá Habr!

Os navegadores da Web implementam lenta mas seguramente a maioria dos recursos do sistema operacional, e há cada vez menos razões para desenvolver um aplicativo nativo se você puder escrever uma versão da Web (PWA). Plataforma cruzada, API avançada, alta velocidade de desenvolvimento em TS / JS e até o desempenho do mecanismo V8 - tudo isso é uma vantagem. Os navegadores têm sido capazes de trabalhar com um fluxo de vídeo e executar redes neurais, ou seja, temos todos os componentes para criar um sistema de vigilância por vídeo com reconhecimento de objetos. Inspirado por este artigo , decidi levar a demonstração ao nível de aplicação prática que desejo compartilhar.

O aplicativo grava vídeos da câmera, enviando periodicamente quadros para reconhecimento no COCO-SSD, e se uma pessoa for detectada, fragmentos de vídeo em partes de 7 segundos começam a ser enviados para o e-mail especificado por meio da API do Gmail. Como nos sistemas adultos, a pré-gravação é realizada, ou seja, salvamos um fragmento até o momento da detecção, todos os fragmentos com detecção e um depois. Se a Internet não estiver disponível ou ocorrer um erro durante o envio, os vídeos serão salvos na pasta local de Downloads. O uso do email permite que você fique sem o lado do servidor, notifique instantaneamente o proprietário e, se um invasor tomar posse do dispositivo e quebrar todas as senhas, ele não poderá excluir os emails do destinatário. Das desvantagens - um uso excessivo de tráfego devido ao Base64 (embora o suficiente para uma câmera) e a necessidade de coletar o arquivo de vídeo final de muitos e-mails.

A demonstração de trabalho está aqui .

Os problemas encontrados são os seguintes:

1) A rede neural carrega muito o processador e, se você o executar no segmento principal, atrasos aparecerão nos vídeos. Portanto, o reconhecimento é colocado em um thread separado (trabalhador), embora nem tudo seja tranquilo aqui. Tudo é perfeitamente paralelo no Linux pré-histórico de núcleo duplo, mas em alguns telefones celulares de quatro núcleos relativamente novos - no momento do reconhecimento (no trabalhador), o segmento principal também começa a ficar lento, o que é perceptível na interface do usuário. Felizmente, isso não afeta a qualidade do vídeo, embora reduza a frequência de reconhecimento (ajusta-se automaticamente à carga). Esse problema provavelmente está relacionado à forma como as diferentes versões do Android distribuem threads por núcleo, a presença de SIMD, as funções disponíveis da placa de vídeo etc. Não consigo descobrir sozinho, não conheço o interior do TensorFlow e serei grato pelas informações.

2) FireFox. O aplicativo funciona bem no Chrome / Chromium / Edge; no entanto, o reconhecimento no FireFox é notavelmente mais lento; além disso, o ImageCapture ainda não foi implementado (é claro, isso pode ser ignorado capturando um quadro de <video>, mas é uma pena para a raposa, porque é padrão API). Em geral, também não havia acessibilidade completa entre navegadores.

Então, tudo em ordem.

Obtendo uma câmera e um microfone


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

Aqui, selecionamos a câmera principal do celular / tablet (ou a primeira no computador / laptop), exibimos o fluxo em um reprodutor de vídeo padrão, após o qual aguardamos o carregamento dos metadados e definimos as dimensões da tela de serviço. Como todo o aplicativo é gravado no estilo de async / waiting, você precisa converter APIs de retorno de chamada (e existem muitas) em Promise para uniformidade.

Captura de vídeo


Existem duas maneiras de capturar vídeo. A primeira é ler diretamente os quadros do fluxo de entrada, exibi-los na tela, modificá-los (por exemplo, adicionar geografia e data e hora) e, em seguida, coletar os dados da tela - para o gravador como um fluxo de saída e para uma rede neural como imagens separadas. Nesse caso, você pode ficar sem o elemento <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))
}

A segunda maneira (trabalhando em FF) é usar um reprodutor de vídeo padrão para capturar. A propósito, ele consome menos tempo do processador, diferente da exibição quadro a quadro na tela, mas não podemos adicionar uma inscrição.

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

O aplicativo usa a primeira opção, como resultado da qual o player de vídeo pode ser desligado durante o processo de reconhecimento. Para economizar processador, a gravação é realizada a partir do fluxo recebido, e os quadros de desenho na tela são usados ​​apenas para obter uma matriz de pixels para a rede neural, com uma frequência dependendo da velocidade de reconhecimento. Desenhamos o quadro ao redor da pessoa em uma tela separada colocada no player.

Carregamento de rede neural e detecção humana


É tudo indecentemente simples. Iniciamos o trabalhador , depois de carregar o modelo (bastante longo), enviamos uma mensagem vazia para o encadeamento principal, onde no evento onmessage mostramos o botão Iniciar, após o qual o trabalhador está pronto para receber imagens. Código completo do trabalhador:

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

No thread principal, iniciamos a função grab_video () somente depois de receber o resultado anterior do trabalhador, ou seja, a frequência de detecção dependerá da carga do sistema.

Gravação de vídeo


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)

Em cada parada do gravador (usamos um intervalo fixo), o evento ondataavailable é gerado, onde o fragmento gravado no formato Blob é transferido, salvo neste pedaço e enviado de forma assíncrona. Sim, this.send_chunk () retorna uma promessa, mas a função leva muito tempo (codificação em Base64, enviando um email ou salvando o arquivo localmente), e não esperamos que seja executado e não processe o resultado - portanto, não há espera. Mesmo que os novos videoclipes apareçam com mais frequência do que podem ser enviados, o mecanismo JS organiza a linha de promessas de forma transparente para o desenvolvedor, e todos os dados serão enviados / gravados mais cedo ou mais tarde. A única coisa que vale a pena prestar atenção é dentro da função send_chunk () antes da primeira espera, é necessário clonar o Blob com o método slice (), pois o link this.chunk é esfregado a cada CHUNK_DURATION segundos.

API do Gmail


Usado para enviar cartas. A API é bastante antiga, em parte em promessas, em parte em retornos de chamada, a documentação e os exemplos não são abundantes; portanto, darei o código completo.

Autorização . obtemos as chaves do aplicativo e do cliente no console do desenvolvedor do Google. Em uma janela de autorização pop-up, o Google relata que o aplicativo não foi verificado e você precisará clicar em "configurações avançadas" para entrar. Verificar a aplicação no Google acabou por ser uma tarefa não trivial, você precisa confirmar a propriedade do domínio (que eu não tenho), organizar corretamente a página principal, então decidi não me incomodar.

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

Envio de email . As cadeias codificadas em Base64 não podem ser concatenadas, e isso é inconveniente. Como enviar vídeo em formato binário, eu ainda não entendi. Nas últimas linhas, convertemos o retorno de chamada em uma promessa. Infelizmente, isso tem que ser feito com bastante frequência.

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

Salvando um videoclipe em disco. Usamos um hiperlink oculto.

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

Gerenciamento de estado no mundo dos componentes da web


Continuando a idéia apresentada neste artigo , levei-a ao absurdo do fim lógico (apenas para os lulz) e virei o controle do estado de cabeça para baixo. Se geralmente as variáveis ​​JS são consideradas como um estado e o DOM é apenas a exibição atual, no meu caso a fonte de dados é o próprio DOM (já que os componentes da Web são os nós DOM de longa duração) e, para usar dados no lado JS, os componentes da Web fornecem getters / setters para cada campo de formulário. Assim, por exemplo, em vez de caixas de seleção desconfortáveis ​​no estilo, <button> simples é usado e o "valor" do botão (true é pressionado, false é pressionado) é o valor do atributo de classe, que permite estilizá-lo da seguinte maneira:

button.true {background-color: red}

e obtenha o valor assim:

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

Não posso aconselhar usar isso na produção, porque essa é uma boa maneira de diminuir a produtividade. Embora ... o DOM virtual também não seja gratuito, e eu não fiz benchmarks.

Modo offline


Por fim, adicione um pouco de PWA, a saber, instale um trabalhador de serviço que armazenará em cache todas as solicitações de rede e permitirá que o aplicativo funcione sem acesso à Internet. Uma pequena nuance - em artigos sobre trabalhadores de serviço, eles geralmente fornecem o seguinte algoritmo:

  • No evento de instalação - crie uma nova versão do cache e adicione todos os recursos necessários ao cache.
  • No evento de ativação - exclua todas as versões do cache, exceto a atual.
  • No evento de busca - primeiro tentamos pegar o recurso do cache e, se não o encontrarmos, enviamos uma solicitação de rede, cujo resultado é adicionado ao cache.

Na prática, esse esquema é inconveniente por duas razões. Em primeiro lugar, no código do trabalhador, você precisa ter uma lista atualizada de todos os recursos necessários e, em grandes projetos usando bibliotecas de terceiros, tente acompanhar todas as importações anexadas (incluindo as dinâmicas). O segundo problema - ao alterar qualquer arquivo, você precisa aumentar a versão do trabalhador do serviço, o que levará à instalação de um novo trabalhador e à invalidação do anterior, e isso acontecerá SOMENTE quando o navegador for fechado / aberto. Uma simples atualização de página não ajudará - o trabalhador antigo com o cache antigo funcionará. E onde está a garantia de que meus clientes não manterão a guia do navegador para sempre? Portanto, primeiro fazemos uma solicitação de rede, adicionamos o resultado ao cache de forma assíncrona (sem aguardar a resolução de permissão cache.put (ev.request, resp.clone ())) e, se a rede não estiver disponível, obtemos o mesmo do cache. Melhor perder um diadepois voe em 5 minutos ©.

Questões não resolvidas


  1. Em alguns telefones celulares, a rede neural diminui, talvez no meu caso, o COCO-SSD não seja a melhor opção, mas eu não sou especialista em ML e peguei a primeira que foi ouvida.
  2. Não encontrei um exemplo de como enviar vídeo via GAPI, não no formato Base64, mas no binário original. Isso economizaria tempo do processador e tráfego de rede.
  3. Eu não entendi segurança. Para fins de depuração local, adicionei o domínio localhost ao aplicativo do Google, mas se alguém começar a usar as chaves do aplicativo para enviar spam - o Google bloqueará as chaves ou a conta do remetente?

Ficaria muito grato pelo feedback.

Fontes no github.

Obrigado pela atenção.

All Articles