- web/ com Vite + Svelte 5 + TypeScript; servidor entrega web/dist em "/" (telas antigas seguem em /dashboard, /painel, /braco...) - design system em tokens CSS, tema escuro e claro, fontes locais - estado unico: foto de /estado + um EventSource em /eventos, sem polling - shell com navegacao lateral/inferior, selos de status reais e E-Stop fixo - command palette (Ctrl+K), gestos favoritos com atalhos 1-9, g+letra navega - Ao vivo: camera WebRTC e rastreamento portados para TS, rosto, aparencia, sensores do celular, IA local e feed de acontecimentos - Ajustes: campos do celular agrupados com busca, salvamento otimista e restaurar padrao (GET /app/padrao); motor da conversa, Gemini e token - carinha.html ganha modo ?painel=1 dirigido por postMessage (sem polling) - teste Playwright de ponta a ponta do painel novo Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
252 lines
9.1 KiB
TypeScript
252 lines
9.1 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 type { AlvoRastreio, VideoAoVivo } from './video.svelte';
|
|
|
|
interface Ponto {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
interface Face extends Ponto {
|
|
w: number;
|
|
h: number;
|
|
score: number;
|
|
pontos?: Ponto[];
|
|
}
|
|
interface Resultado {
|
|
faces: Face[];
|
|
hands: Ponto[][];
|
|
ms: number;
|
|
timestamp: number;
|
|
}
|
|
|
|
const valido = (p: Ponto | undefined) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y);
|
|
const limitar = (v: number) => Math.max(0, Math.min(1, v));
|
|
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 function escolherAlvo(faces: Face[], maos: Ponto[][], anterior: AlvoRastreio | null): AlvoRastreio | null {
|
|
let candidatos: AlvoRastreio[] = maos
|
|
.filter((h) => h.length === 21 && h.every(valido))
|
|
.map((h) => {
|
|
const palma = [0, 5, 9, 13, 17].map((i) => h[i]);
|
|
return { tipo: 'mao', x: palma.reduce((s, p) => s + p.x, 0) / 5, y: palma.reduce((s, p) => s + p.y, 0) / 5 };
|
|
});
|
|
if (!candidatos.length)
|
|
candidatos = faces
|
|
.filter((f) => valido(f) && f.w > 0 && f.h > 0)
|
|
.sort((a, b) => b.w * b.h - a.w * a.h)
|
|
.map((f) => ({ tipo: 'rosto', x: f.x + f.w / 2, y: f.y + f.h / 2 }));
|
|
if (!candidatos.length) return null;
|
|
if (anterior && anterior.tipo === candidatos[0].tipo)
|
|
candidatos.sort((a, b) => Math.hypot(a.x - anterior.x, a.y - anterior.y) - Math.hypot(b.x - anterior.x, b.y - anterior.y));
|
|
const t = candidatos[0];
|
|
return { ...t, x: limitar(t.x), y: limitar(t.y) };
|
|
}
|
|
|
|
function encaixe(w: number, h: number, vw: number, vh: number) {
|
|
const escala = Math.min(w / vw, h / vh);
|
|
return { x: Math.max(0, (w - vw * escala) / 2), y: Math.max(0, (h - vh * escala) / 2), w: vw * escala, h: vh * escala };
|
|
}
|
|
|
|
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 * 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.');
|
|
}
|
|
}
|