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