Files
carinha-control-panel/web/src/lib/rastreamento.svelte.ts
T
julioandClaude Opus 5.5 c5fd54c1d9 feat(celular): rosto e app usam SSE e token; remove telas legadas
- 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>
2026-10-05 20:11:58 -03:00

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.');
}
}