/** * Lógica pura do painel (sem Svelte nem DOM), testada com `npm test` (node --test). * Rastreamento: escolha do alvo e encaixe do vídeo. Braço: faixas calibradas e sequências. */ export interface Ponto { x: number; y: number; } export interface Face extends Ponto { w: number; h: number; score?: number; pontos?: Ponto[]; } export interface AlvoRastreio { tipo: 'mao' | 'rosto'; x: number; y: number; atividade?: boolean; } const valido = (p: Ponto | undefined) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y); const limitar01 = (v: number) => Math.max(0, Math.min(1, v)); /** Mão tem prioridade sobre rosto; entre candidatos do mesmo tipo, o mais perto do alvo anterior. */ export function escolherAlvo(faces: Face[], maos: Ponto[][], anterior: AlvoRastreio | null = 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) && Number.isFinite(f.w) && Number.isFinite(f.h) && 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: limitar01(t.x), y: limitar01(t.y) }; } /** Retângulo do vídeo (vw×vh) encaixado em w×h sem cortar (object-fit: contain). */ export 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 interface Calibracao { canal: number; minimo: number; maximo: number; centro?: number; } function anguloDoPulso(p: number, c: Calibracao) { const centro = c.centro ?? 1500; return p <= centro ? (90 * (p - 500)) / (centro - 500) : 90 + (90 * (p - centro)) / (2500 - centro); } /** Faixa segura (graus) de cada articulação; o canal 5 respeita o par 6 invertido. */ export function limites(calibracao: Calibracao[], articulacoes: { canal: number }[]): [number, number][] { return articulacoes.map(({ canal }) => { const c = calibracao[canal]; let lo = anguloDoPulso(c.minimo, c); let hi = anguloDoPulso(c.maximo, c); if (canal === 5) { const par = calibracao[6]; lo = Math.max(lo, 180 - anguloDoPulso(par.maximo, par)); hi = Math.min(hi, 180 - anguloDoPulso(par.minimo, par)); } return [Math.ceil(lo), Math.floor(hi)]; }); } /** As 5 sequências prontas, escaladas para a faixa calibrada de cada articulação. */ export function sequencias(faixas: [number, number][]): { nome: string; passos: number[][] }[] { const neutro = [0, 0, 0, 0, 0, 0, 0]; const base: [string, number[][]][] = [ ['Acenar', [[0, -0.7, 0.5, 0.2, 0, 0.1, 0], [0, 0.7, -0.5, 0.2, 0, 0.1, 0], [0, -0.7, 0.5, 0.2, 0, 0.1, 0], [0, 0.7, -0.5, 0.2, 0, 0.1, 0], neutro]], ['Olhar para os lados', [[0, 0.2, -0.4, 0, 0, 0, -0.7], [0, -0.2, 0.4, 0, 0, 0, 0.7], neutro]], ['Alcançar e recolher', [[0, 0, 0.2, 0.6, -0.5, 0.4, 0], [0, 0, -0.2, -0.4, 0.5, -0.4, 0], neutro]], ['Garra e punho', [[-0.7, 0.3, 0, 0.2, 0, 0.1, 0], [0.7, -0.3, 0, 0.2, 0, 0.1, 0], [-0.7, 0.3, 0, 0, 0, 0, 0], neutro]], ['Apresentação', [[0.3, 0.3, -0.3, 0.4, -0.3, 0.3, -0.6], [-0.3, -0.3, 0.3, -0.3, 0.4, -0.3, 0.6], [0.5, 0.4, 0.2, 0.3, -0.3, 0.2, 0], neutro]], ]; return base.map(([nome, passos]) => ({ nome, passos: passos.map((s) => s.map((v, i) => { const [lo, hi] = faixas[i]; const meio = Math.max(lo, Math.min(hi, 90)); return Math.round(meio + v * (v < 0 ? meio - lo : hi - meio)); }), ), })); }