- carinha.html assina /eventos (expressao, app_config, conversa, reacao, recarregar) e volta ao polling de /fila sozinho se o SSE cair; foto dos ajustes a cada (re)conexao; token (?t=) vai em todo fetch ao servidor - APK 0.8-token: token nas preferencias e no menu de toque longo, enviado na pagina, no HTTP do Java e ao llama-server (--api-key); trocar servidor por Intent exige o token salvo; camera/microfone so para o servidor configurado ou a copia local - cerebro/conversa mandam a chave ao llama-server do S10 - servidor: evento SSE 'recarregar'; remove /camera/frame e /app/connect; enderecos antigos redirecionam para a secao do painel novo - remove dashboard, painel, conversa, emojis, treinamento, braco, servos, api, scripts e CSS antigos, audio-robot.js e servir-local.bat - logica pura do painel em web/src/lib/nucleo.ts com testes node --test (substitui test_movimentos.js e test_rastreamento.js) - testes de navegador portados para o painel novo (rtc, rosto, rastreamento) - README, docs/PAINEL.md e docs/SEGURANCA.md reescritos; versao 0.8.0; carinha-pc.service le /etc/carinha.env Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
7.7 KiB
TypeScript
217 lines
7.7 KiB
TypeScript
/**
|
|
* Rastreamento de rosto/mãos no navegador do PC (MediaPipe num Worker servido pelo
|
|
* servidor em /rastreador-worker.js). Porta de painel-rastreamento.js + rastreamento-core.js.
|
|
*/
|
|
import type { AppConfig } from './tipos';
|
|
import { encaixe, escolherAlvo, type AlvoRastreio, type Face, type Ponto } from './nucleo';
|
|
import type { VideoAoVivo } from './video.svelte';
|
|
|
|
interface Resultado {
|
|
faces: Face[];
|
|
hands: Ponto[][];
|
|
ms: number;
|
|
timestamp: number;
|
|
}
|
|
|
|
const LIGACOES = [[0, 1], [1, 2], [2, 3], [3, 4], [0, 5], [5, 6], [6, 7], [7, 8], [5, 9], [9, 10], [10, 11], [11, 12],
|
|
[9, 13], [13, 14], [14, 15], [15, 16], [13, 17], [0, 17], [17, 18], [18, 19], [19, 20]];
|
|
|
|
export class Rastreamento {
|
|
texto = $state('Rastreamento desligado.');
|
|
|
|
private worker: Worker | null = null;
|
|
private pronto = false;
|
|
private pendente = false;
|
|
private geracao = 0;
|
|
private ativo = false;
|
|
private config: AppConfig = {};
|
|
private resultado: Resultado | null = null;
|
|
private alvo: AlvoRastreio | null = null;
|
|
private alvoEm = 0;
|
|
private em = 0;
|
|
private intervalo = 50;
|
|
private proximo = 0;
|
|
private ultimoVideo = -1;
|
|
private tentarEm = 0;
|
|
private amostraAnterior = 0;
|
|
private rascunho = document.createElement('canvas');
|
|
private relogio: number;
|
|
private ctx: CanvasRenderingContext2D;
|
|
|
|
constructor(
|
|
private video: HTMLVideoElement,
|
|
private tela: HTMLCanvasElement,
|
|
private saida: VideoAoVivo,
|
|
) {
|
|
this.ctx = tela.getContext('2d')!;
|
|
this.relogio = window.setInterval(() => this.passo(), 20);
|
|
addEventListener('resize', this.desenhar);
|
|
}
|
|
|
|
private limpar() {
|
|
this.ctx.clearRect(0, 0, this.tela.width, this.tela.height);
|
|
}
|
|
|
|
private parar(texto: string) {
|
|
if (this.ativo) this.saida.rastrear(null);
|
|
this.ativo = false;
|
|
this.geracao++;
|
|
this.worker?.terminate();
|
|
this.worker = null;
|
|
this.pendente = this.pronto = false;
|
|
this.resultado = this.alvo = null;
|
|
this.ultimoVideo = -1;
|
|
this.amostraAnterior = 0;
|
|
this.limpar();
|
|
this.texto = texto;
|
|
}
|
|
|
|
private desenhar = () => {
|
|
const caixa = this.tela.parentElement!.getBoundingClientRect();
|
|
if (this.tela.width !== Math.round(caixa.width) || this.tela.height !== Math.round(caixa.height)) {
|
|
this.tela.width = Math.round(caixa.width);
|
|
this.tela.height = Math.round(caixa.height);
|
|
}
|
|
this.limpar();
|
|
this.tela.hidden = !this.config.mostrarRastreamento;
|
|
const r = this.resultado;
|
|
if (this.tela.hidden || !r || performance.now() - this.em > 350 || !this.video.videoWidth) return;
|
|
const f = encaixe(this.tela.width, this.tela.height, this.video.videoWidth, this.video.videoHeight);
|
|
const pt = (p: Ponto) => ({ x: f.x + p.x * f.w, y: f.y + p.y * f.h });
|
|
const c = this.ctx;
|
|
c.lineWidth = 2;
|
|
c.font = '13px system-ui';
|
|
const bola = (p: Ponto, raio = 3) => {
|
|
c.beginPath();
|
|
c.arc(p.x, p.y, raio, 0, Math.PI * 2);
|
|
c.fill();
|
|
};
|
|
for (const rosto of r.faces) {
|
|
const p = pt(rosto);
|
|
c.strokeStyle = c.fillStyle = '#5cd6ff';
|
|
c.strokeRect(p.x, p.y, rosto.w * f.w, rosto.h * f.h);
|
|
c.fillText('Rosto ' + Math.round((rosto.score ?? 0) * 100) + '%', p.x, Math.max(15, p.y - 6));
|
|
for (const q of rosto.pontos || []) bola(pt(q));
|
|
}
|
|
for (const mao of r.hands) {
|
|
c.strokeStyle = c.fillStyle = '#4ade80';
|
|
for (const [a, b] of LIGACOES) {
|
|
const p = pt(mao[a]);
|
|
const q = pt(mao[b]);
|
|
c.beginPath();
|
|
c.moveTo(p.x, p.y);
|
|
c.lineTo(q.x, q.y);
|
|
c.stroke();
|
|
}
|
|
for (const q of mao) bola(pt(q));
|
|
}
|
|
if (this.alvo) {
|
|
const p = pt(this.alvo);
|
|
c.strokeStyle = '#fbbf24';
|
|
c.beginPath();
|
|
c.arc(p.x, p.y, 12, 0, Math.PI * 2);
|
|
c.moveTo(p.x - 18, p.y);
|
|
c.lineTo(p.x + 18, p.y);
|
|
c.moveTo(p.x, p.y - 18);
|
|
c.lineTo(p.x, p.y + 18);
|
|
c.stroke();
|
|
}
|
|
};
|
|
|
|
private iniciar() {
|
|
this.ativo = true;
|
|
const geracao = ++this.geracao;
|
|
this.worker = new Worker('/rastreador-worker.js');
|
|
this.texto = 'Carregando rastreadores locais…';
|
|
this.worker.onerror = (e) => {
|
|
this.tentarEm = Date.now() + 10000;
|
|
this.parar('Falha no rastreador: ' + e.message);
|
|
};
|
|
this.worker.onmessage = ({ data }) => {
|
|
if (geracao !== this.geracao) return;
|
|
this.pendente = false;
|
|
if (data.tipo === 'erro') {
|
|
this.tentarEm = Date.now() + 10000;
|
|
this.parar('Rastreamento indisponível: ' + data.erro);
|
|
return;
|
|
}
|
|
this.pronto = true;
|
|
if (data.tipo === 'pronto') {
|
|
this.texto = 'Rastreadores prontos. Aguardando imagem…';
|
|
return;
|
|
}
|
|
const r = data as Resultado;
|
|
this.resultado = r;
|
|
this.em = r.timestamp;
|
|
const anterior = this.alvo;
|
|
const alvo = escolherAlvo(r.faces, r.hands, anterior);
|
|
if (alvo) alvo.atividade = !!anterior && alvo.tipo === anterior.tipo && Math.hypot(alvo.x - anterior.x, alvo.y - anterior.y) > 0.008;
|
|
this.alvo = alvo;
|
|
this.alvoEm = r.timestamp;
|
|
this.intervalo = Math.max(50, Math.min(200, r.ms * 1.2));
|
|
const hz = this.amostraAnterior ? Math.round(1000 / (r.timestamp - this.amostraAnterior)) : 0;
|
|
this.amostraAnterior = r.timestamp;
|
|
this.texto = `${r.faces.length} rosto(s) · ${r.hands.length} mão(s) · ${hz} análises/s · ${Math.round(r.ms)} ms${
|
|
alvo ? ' · alvo: ' + (alvo.tipo === 'mao' ? 'mão' : 'rosto') : ' · procurando…'
|
|
}`;
|
|
if (this.config.seguirRastreamento) this.saida.rastrear(alvo);
|
|
this.desenhar();
|
|
};
|
|
this.pendente = true;
|
|
this.worker.postMessage({ config: { rosto: this.config.rastrearRosto, maos: this.config.rastrearMaos } });
|
|
}
|
|
|
|
private async passo() {
|
|
const agora = performance.now();
|
|
if (this.alvo && agora - this.alvoEm > 350) {
|
|
this.alvo = null;
|
|
if (this.config.seguirRastreamento) this.saida.rastrear(null);
|
|
}
|
|
if (this.resultado && agora - this.em > 350) {
|
|
this.resultado = null;
|
|
this.limpar();
|
|
}
|
|
const v = this.video;
|
|
if (!this.ativo || !this.pronto || this.pendente || agora < this.proximo || v.readyState < 2 || v.currentTime === this.ultimoVideo)
|
|
return;
|
|
this.pendente = true;
|
|
this.proximo = agora + this.intervalo;
|
|
this.ultimoVideo = v.currentTime;
|
|
const geracao = this.geracao;
|
|
try {
|
|
const escala = Math.min(1, 640 / v.videoWidth);
|
|
this.rascunho.width = Math.round(v.videoWidth * escala);
|
|
this.rascunho.height = Math.round(v.videoHeight * escala);
|
|
this.rascunho.getContext('2d')!.drawImage(v, 0, 0, this.rascunho.width, this.rascunho.height);
|
|
const bitmap = await createImageBitmap(this.rascunho);
|
|
if (geracao !== this.geracao) return bitmap.close();
|
|
this.worker!.postMessage(
|
|
{ bitmap, timestamp: agora, config: { rosto: this.config.rastrearRosto, maos: this.config.rastrearMaos } },
|
|
[bitmap],
|
|
);
|
|
} catch (e) {
|
|
if (geracao === this.geracao) this.parar('Falha ao analisar vídeo: ' + (e as Error).message);
|
|
}
|
|
}
|
|
|
|
atualizar(config: AppConfig | null, permitido: boolean) {
|
|
if (!config) return;
|
|
const mudou = this.config.rastrearRosto !== config.rastrearRosto || this.config.rastrearMaos !== config.rastrearMaos;
|
|
if (this.config.seguirRastreamento && !config.seguirRastreamento) this.saida.rastrear(null);
|
|
this.config = config;
|
|
if (!permitido || (!config.rastrearRosto && !config.rastrearMaos)) {
|
|
this.parar(!permitido ? 'Ligue a transmissão para rastrear no PC.' : 'Rastreamento desligado.');
|
|
return;
|
|
}
|
|
if (mudou && this.ativo) this.parar('Atualizando rastreadores…');
|
|
if (!this.ativo && Date.now() >= this.tentarEm) this.iniciar();
|
|
this.desenhar();
|
|
}
|
|
|
|
destruir() {
|
|
clearInterval(this.relogio);
|
|
removeEventListener('resize', this.desenhar);
|
|
this.parar('Painel fechado.');
|
|
}
|
|
}
|