- 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>
100 lines
4.0 KiB
TypeScript
100 lines
4.0 KiB
TypeScript
/**
|
||
* 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));
|
||
}),
|
||
),
|
||
}));
|
||
}
|