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>
This commit is contained in:
+2
-1
@@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "node --test \"tests/**/*.test.ts\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
import { pedir, postar } from './api';
|
||||
import { avisos } from './avisos.svelte';
|
||||
import { estado } from './estado.svelte';
|
||||
import { limites } from './nucleo';
|
||||
|
||||
export { sequencias } from './nucleo';
|
||||
|
||||
export interface Calibracao {
|
||||
canal: number;
|
||||
@@ -33,49 +36,6 @@ export interface StatusBraco {
|
||||
export const PRAZO_MS = 450;
|
||||
const RENOVAR_MS = 150;
|
||||
|
||||
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 a partir da calibração; o canal 5 respeita o par 6 invertido. */
|
||||
export function limites(st: StatusBraco): [number, number][] {
|
||||
return st.articulacoes.map(({ canal }) => {
|
||||
const c = st.calibracao[canal];
|
||||
let lo = anguloDoPulso(c.minimo, c);
|
||||
let hi = anguloDoPulso(c.maximo, c);
|
||||
if (canal === 5) {
|
||||
const par = st.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 (porta de movimentos-core.js). */
|
||||
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];
|
||||
// Centro 90°, mas sempre dentro da faixa calibrada.
|
||||
const meio = Math.max(lo, Math.min(hi, 90));
|
||||
return Math.round(meio + v * (v < 0 ? meio - lo : hi - meio));
|
||||
}),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
class Braco {
|
||||
status = $state<StatusBraco | null>(null);
|
||||
alvo = $state<number[]>(Array(7).fill(90));
|
||||
@@ -90,7 +50,7 @@ class Braco {
|
||||
private sequencia: number[][] | null = null;
|
||||
private pausaAte = 0;
|
||||
|
||||
faixas = $derived(this.status ? limites(this.status) : []);
|
||||
faixas = $derived(this.status ? limites(this.status.calibracao, this.status.articulacoes) : []);
|
||||
|
||||
async carregar() {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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));
|
||||
}),
|
||||
),
|
||||
}));
|
||||
}
|
||||
@@ -3,18 +3,9 @@
|
||||
* 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';
|
||||
import { encaixe, escolherAlvo, type AlvoRastreio, type Face, type Ponto } from './nucleo';
|
||||
import type { 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[][];
|
||||
@@ -22,35 +13,9 @@ interface Resultado {
|
||||
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.');
|
||||
|
||||
@@ -125,7 +90,7 @@ export class Rastreamento {
|
||||
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));
|
||||
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) {
|
||||
|
||||
@@ -4,14 +4,10 @@
|
||||
* Só a tela Ao vivo cria uma instância: assim não há disputa pela única sessão.
|
||||
*/
|
||||
import { lerToken, pedir } from './api';
|
||||
import type { AlvoRastreio } from './nucleo';
|
||||
import type { AppConfig, Telemetria } from './tipos';
|
||||
|
||||
export interface AlvoRastreio {
|
||||
tipo: 'mao' | 'rosto';
|
||||
x: number;
|
||||
y: number;
|
||||
atividade?: boolean;
|
||||
}
|
||||
export type { AlvoRastreio } from './nucleo';
|
||||
|
||||
export class VideoAoVivo {
|
||||
mensagem = $state('Transmissão desligada.');
|
||||
@@ -186,6 +182,10 @@ export class VideoAoVivo {
|
||||
}
|
||||
}
|
||||
|
||||
get rastreamentoConectado() {
|
||||
return this.canal?.readyState === 'open';
|
||||
}
|
||||
|
||||
rastrear(alvo: AlvoRastreio | null) {
|
||||
if (this.canal?.readyState === 'open' && this.canal.bufferedAmount < 4096)
|
||||
this.canal.send(JSON.stringify({ tipo: 'alvo', seq: ++this.seqRastreio, alvo }));
|
||||
|
||||
@@ -22,10 +22,12 @@
|
||||
onMount(() => {
|
||||
video = new VideoAoVivo(elVideo);
|
||||
rastreio = new Rastreamento(elVideo, elTela, video);
|
||||
(window as any).carinhaVideo = video; // depuração e testes de ponta a ponta
|
||||
});
|
||||
onDestroy(() => {
|
||||
rastreio?.destruir();
|
||||
video?.destruir();
|
||||
delete (window as any).carinhaVideo;
|
||||
});
|
||||
|
||||
// Vídeo e rastreamento seguem o estado do servidor (SSE), sem polling próprio.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// node --test (Node 22.6+ remove os tipos sozinho). Substitui tests/test_movimentos.js e test_rastreamento.js.
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { encaixe, escolherAlvo, limites, sequencias } from '../src/lib/nucleo.ts';
|
||||
|
||||
test('mão tem prioridade sobre rosto e entradas inválidas são ignoradas', () => {
|
||||
const faces = [{ x: 0.2, y: 0.1, w: 0.3, h: 0.4, score: 0.9 }];
|
||||
const maos = [Array.from({ length: 21 }, () => ({ x: 0.8, y: 0.6 }))];
|
||||
assert.equal(escolherAlvo(faces, maos)?.tipo, 'mao');
|
||||
const rosto = escolherAlvo(faces, [])!;
|
||||
assert.equal(rosto.tipo, 'rosto');
|
||||
assert.ok(Math.abs(rosto.x - 0.35) < 1e-9 && Math.abs(rosto.y - 0.3) < 1e-9);
|
||||
assert.equal(escolherAlvo([], []), null);
|
||||
assert.equal(escolherAlvo([{ x: NaN, y: 0, w: 1, h: 1 }], []), null);
|
||||
});
|
||||
|
||||
test('entre rostos, segue o mais perto do alvo anterior', () => {
|
||||
const faces = [
|
||||
{ x: 0.0, y: 0.0, w: 0.4, h: 0.4 },
|
||||
{ x: 0.7, y: 0.7, w: 0.2, h: 0.2 },
|
||||
];
|
||||
assert.equal(escolherAlvo(faces, [])?.x, 0.2);
|
||||
assert.ok(Math.abs(escolherAlvo(faces, [], { tipo: 'rosto', x: 0.8, y: 0.8 })!.x - 0.8) < 1e-9);
|
||||
});
|
||||
|
||||
test('encaixe centraliza sem cortar', () => {
|
||||
const r = encaixe(1000, 500, 640, 480);
|
||||
assert.ok(Math.abs(r.x - (500 - (500 * 640) / 480 / 2)) < 1e-9);
|
||||
assert.equal(r.y, 0);
|
||||
assert.ok(Math.abs(r.h - 500) < 1e-9);
|
||||
});
|
||||
|
||||
test('sequências respeitam a faixa calibrada e voltam ao neutro', () => {
|
||||
const faixas = Array.from({ length: 7 }, (): [number, number] => [81, 99]);
|
||||
const seqs = sequencias(faixas);
|
||||
assert.equal(seqs.length, 5);
|
||||
for (const s of seqs) {
|
||||
assert.ok(s.passos.length >= 3);
|
||||
for (const passo of s.passos) {
|
||||
assert.equal(passo.length, 7);
|
||||
passo.forEach((v) => assert.ok(v >= 81 && v <= 99, `${s.nome}: ${v}`));
|
||||
}
|
||||
}
|
||||
assert.deepEqual(seqs[0].passos.at(-1), Array(7).fill(90));
|
||||
// Faixa que não contém 90°: o neutro vira o ponto mais próximo dentro dela.
|
||||
const deslocada = sequencias(Array.from({ length: 7 }, (): [number, number] => [100, 140]));
|
||||
assert.deepEqual(deslocada[0].passos.at(-1), Array(7).fill(100));
|
||||
});
|
||||
|
||||
test('limites seguem a calibração e o par 5/6 invertido', () => {
|
||||
const cal = Array.from({ length: 8 }, (_, canal) => ({ canal, minimo: 1400, maximo: 1600, centro: 1500 }));
|
||||
const art = [0, 1, 2, 3, 4, 5, 7].map((canal) => ({ canal }));
|
||||
const f = limites(cal, art);
|
||||
assert.deepEqual(f[0], [81, 99]);
|
||||
cal[6] = { canal: 6, minimo: 1450, maximo: 1600, centro: 1500 };
|
||||
assert.deepEqual(limites(cal, art)[5], [81, 94]);
|
||||
});
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
const alvo = process.env.CARINHA_SERVIDOR || 'http://127.0.0.1:8100';
|
||||
const api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos',
|
||||
'/app', '/rtc', '/camera', '/conversa', '/treinamento', '/braco', '/servos', '/ia', '/api', '/gesto', '/face',
|
||||
'/carinha.html', '/reacoes-render.js', '/rastreador-worker.js', '/vendor', '/audio-robot.js', '/qualidade-auto.js'];
|
||||
'/carinha.html', '/reacoes-render.js', '/rastreador-worker.js', '/vendor', '/qualidade-auto.js', '/app.apk'];
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
|
||||
Reference in New Issue
Block a user