feat(pessoas): reconhecimento de quem chega, só na placa

- reconhecimento.py: cada foto de evento passa pelo YuNet (acha rostos)
  e pelo SFace (impressão de 128 números) no OpenCV da placa (~0,3 s por
  foto); compara com as amostras marcadas (cosseno >= 0,40). Rostos sem
  nome ficam agrupados por semelhança para marcar de uma vez; marcar uma
  pessoa reconhece também os rostos antigos parecidos. Procura nas fotos
  guardadas, renomear, apagar (apaga recortes e impressões), sem nome
  somem em 7 dias, liga/desliga.
- modo vida: espera até 8 s a foto para cumprimentar pelo nome ("Ana!
  Senti sua falta!"), saudade de cada pessoa, "Oi, Bia!" para quem chega
  com alguém já por perto, afinidade com quem dá carinho logo depois.
- painel: tela Pessoas e tópico no guia; console mostra "Reconheceu Ana".
- sistema/reconhecimento/instalar.sh: python3-opencv e os modelos do
  opencv_zoo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-11 04:38:10 -03:00
co-authored by Claude Opus 5.5
parent 2933407b9e
commit 938467e318
13 changed files with 1079 additions and 21 deletions
+4 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Bell, Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Terminal, Video } from '@lucide/svelte';
import { Bell, Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Terminal, Users, Video } from '@lucide/svelte';
import { onMount, type Component } from 'svelte';
import Avisos from './lib/componentes/Avisos.svelte';
import EStop from './lib/componentes/EStop.svelte';
@@ -22,6 +22,7 @@
import Criar from './telas/Criar.svelte';
import DiaADia from './telas/DiaADia.svelte';
import Console from './telas/Console.svelte';
import Pessoas from './telas/Pessoas.svelte';
import Ajuda from './lib/componentes/Ajuda.svelte';
import { TOPICO_DA_TELA } from './lib/guia';
@@ -37,11 +38,13 @@
criar: Criar,
'dia-a-dia': DiaADia,
console: Console,
pessoas: Pessoas,
};
const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video,
eventos: Bell,
console: Terminal,
pessoas: Users,
conversa: MessageCircle,
reacoes: Smile,
movimento: Hand,
+1 -1
View File
@@ -188,7 +188,7 @@ function abrirEventos() {
if (estado.conexao !== 'token') estado.conexao = 'caiu';
};
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
'reacao', 'treinamento', 'historico', 'pir', 'vida', 'aprendizado', 'criacao', 'inspiracao', 'console']) {
'reacao', 'treinamento', 'historico', 'pir', 'vida', 'aprendizado', 'criacao', 'inspiracao', 'console', 'pessoas']) {
fonte.addEventListener(tipo, (e) => {
const dados = JSON.parse((e as MessageEvent).data);
tratadores[tipo]?.(dados);
+16 -2
View File
@@ -242,6 +242,20 @@ export const TOPICOS: Record<string, TopicoGuia> = {
{ emoji: '🧠', nome: 'IA local / Aprendizado / Ideias', texto: 'Escolhas da IA do S10, decisões tiradas da rotina e as ideias do dia.' },
],
},
pessoas: {
titulo: 'Reconhecimento de pessoas',
emoji: '🧑‍🤝‍🧑',
onde: 'Pessoas (menu lateral)',
resumo:
'Quando o sensor vê alguém, o celular manda uma foto e a placa procura rostos nela (YuNet) e compara com quem ele conhece (SFace). Reconheceu: cumprimenta pelo nome e a saudade é de cada pessoa ("Ana! Senti sua falta!"). Tudo roda e fica na placa; nada vai para a internet.',
itens: [
{ emoji: '👆', nome: 'Ensinar', texto: 'Em "Rostos sem nome", toque nos rostos da mesma pessoa e escolha quem é (ou crie a pessoa). Com 3 a 5 fotos ele já reconhece bem; marcar uma pessoa também reconhece os rostos antigos parecidos.' },
{ emoji: '🔎', nome: 'Procurar nas fotos guardadas', texto: 'Analisa as fotos de eventos que já existem, para cadastrar as pessoas logo de cara.' },
{ emoji: '💗', nome: 'Afinidade', texto: 'Carinho, brincadeira ou conversa logo depois de reconhecer alguém aumenta a afinidade com essa pessoa: quem tem bastante ganha coração na festa.' },
{ emoji: '🌙', nome: 'Limites', texto: 'Precisa de luz e do rosto virado para a câmera do celular. No escuro, ou de costas, ele cumprimenta sem o nome.' },
{ emoji: '🛡️', nome: 'Privacidade', texto: 'Rostos sem nome somem em 7 dias. Apagar uma pessoa apaga as fotos e as impressões dela. Dá para desligar o reconhecimento a qualquer hora.' },
],
},
jeito: {
titulo: 'Jeito de gente',
emoji: '🧒',
@@ -402,13 +416,13 @@ export const TOPICOS: Record<string, TopicoGuia> = {
/** Ordem do tutorial (página Guia). */
export const ORDEM_GUIA = [
'inicio', 'vida', 'aprendizado', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'jeito', 'console', 'criar', 'ideias', 'aparencia',
'inicio', 'vida', 'aprendizado', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'jeito', 'console', 'pessoas', 'criar', 'ideias', 'aparencia',
'personalidade', 'acontecimentos', 'ligacoes', 'eventos', 'presenca', 'tela', 'sensores', 'sensibilidade',
'reacoes', 'conversa', 'movimento', 'atalhos',
];
/** Tópico de cada tela, para o ⓘ do topo. */
export const TOPICO_DA_TELA: Record<string, string> = {
'ao-vivo': 'inicio', console: 'console', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes',
'ao-vivo': 'inicio', console: 'console', pessoas: 'pessoas', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes',
movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar', 'dia-a-dia': 'aprendizado',
};
+78
View File
@@ -0,0 +1,78 @@
/**
* Reconhecimento de pessoas (/pessoas): quem ele conhece, os rostos que viu e ainda não sabe de quem
* são, e as ações para ensinar ("esta é a Ana"). Tudo fica na placa. Estado por SSE ('pessoas').
*/
import { lerToken, pedir, postar } from './api';
import { avisos, tentar } from './avisos.svelte';
import { ouvir } from './estado.svelte';
export interface FotoRosto { id: number; recorte: string }
export interface Pessoa {
id: number;
nome: string;
visto: number | null;
vezes: number;
afinidade: number;
amostras: number;
fotos: FotoRosto[];
}
export interface RostoSemNome { id: number; horario: number; evento: number; recorte: string; parecido: number; grupo: number }
export interface StatusPessoas {
config: { ligado: boolean };
disponivel: boolean;
motivo: string;
pessoas: Pessoa[];
sem_nome: RostoSemNome[];
/** ids dos rostos sem nome agrupados por semelhança (provavelmente a mesma pessoa) */
grupos: number[][];
rostos: number;
varrendo: { feitas: number; total: number } | null;
limiar: number;
dias_sem_nome: number;
}
export function urlRosto(arquivo: string) {
const t = lerToken();
return '/pessoas/rosto/' + arquivo + (t ? '?t=' + encodeURIComponent(t) : '');
}
class Pessoas {
status = $state<StatusPessoas | null>(null);
async carregar() {
try {
this.status = await pedir<StatusPessoas>('/pessoas');
} catch (e) {
avisos.erro(e);
}
}
private async enviar(rota: string, dados: object, ok?: string) {
const r = await tentar(() => postar<StatusPessoas>(rota, dados), ok);
if (r) this.status = r;
return !!r;
}
marcar(rostos: number[], alvo: { pessoa?: number; nome?: string }) {
const quem = alvo.nome ?? this.status?.pessoas.find((p) => p.id === alvo.pessoa)?.nome;
return this.enviar('/pessoas/marcar', { rostos, ...alvo }, quem ? `Aprendi o rosto de ${quem}.` : undefined);
}
descartar(rostos: number[]) {
return this.enviar('/pessoas/marcar', { rostos }, 'Rostos descartados.');
}
renomear(pessoa: number, nome: string) {
return this.enviar('/pessoas/renomear', { pessoa, nome }, 'Nome trocado.');
}
apagar(pessoa: number) {
return this.enviar('/pessoas/apagar', { pessoa }, 'Pessoa apagada, com todas as fotos dela.');
}
ligar(ligado: boolean) {
return this.enviar('/pessoas/config', { ligado });
}
varrer() {
return this.enviar('/pessoas/varrer', {}, 'Procurando rostos nas fotos guardadas…');
}
}
export const pessoas = new Pessoas();
ouvir('pessoas', (d: StatusPessoas) => (pessoas.status = d));
+2 -1
View File
@@ -1,11 +1,12 @@
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar' | 'dia-a-dia' | 'console';
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar' | 'dia-a-dia' | 'console' | 'pessoas';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
{ id: 'console', titulo: 'Console', atalho: 'g k' },
{ id: 'eventos', titulo: 'Eventos', atalho: 'g e' },
{ id: 'pessoas', titulo: 'Pessoas', atalho: 'g p' },
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
{ id: 'movimento', titulo: 'Movimento', atalho: 'g m' },
+307
View File
@@ -0,0 +1,307 @@
<script lang="ts">
import { Check, Pencil, ScanFace, ShieldCheck, Trash2, UserPlus, X } from '@lucide/svelte';
import { onMount } from 'svelte';
import Card from '../lib/componentes/Card.svelte';
import Selo from '../lib/componentes/Selo.svelte';
import Toggle from '../lib/componentes/Toggle.svelte';
import { estado } from '../lib/estado.svelte';
import { pessoas, urlRosto, type Pessoa } from '../lib/pessoas.svelte';
// Quem ele conhece e os rostos que ainda não sabe de quem são. Marcar "esta é a Ana" ensina;
// com 3 a 5 fotos de cada pessoa ele reconhece bem. Tudo fica na placa.
onMount(() => pessoas.carregar());
const st = $derived(pessoas.status);
let escolhidos = $state<number[]>([]);
let novoNome = $state('');
let editando = $state<number | null>(null);
let nomeEditado = $state('');
let apagando = $state<number | null>(null);
const alternar = (id: number) =>
(escolhidos = escolhidos.includes(id) ? escolhidos.filter((x) => x !== id) : [...escolhidos, id]);
async function paraPessoa(p: Pessoa) {
if (await pessoas.marcar(escolhidos, { pessoa: p.id })) escolhidos = [];
}
async function criar(e: SubmitEvent) {
e.preventDefault();
if (novoNome.trim() && (await pessoas.marcar(escolhidos, { nome: novoNome.trim() }))) {
escolhidos = [];
novoNome = '';
}
}
async function descartar() {
if (await pessoas.descartar(escolhidos)) escolhidos = [];
}
async function salvarNome(p: Pessoa) {
if (nomeEditado.trim() && (await pessoas.renomear(p.id, nomeEditado.trim()))) editando = null;
}
function quando(s: number | null) {
if (!s) return 'ainda não foi visto';
const min = Math.round((estado.agora / 1000 - s) / 60);
if (min < 1) return 'visto agora há pouco';
if (min < 60) return `visto há ${min} min`;
if (min < 1440) return `visto há ${Math.round(min / 60)} h`;
return `visto há ${Math.round(min / 1440)} dia(s)`;
}
</script>
<div class="pessoas">
<Card titulo="Reconhecimento de pessoas" ajuda="pessoas" descricao="Pelas fotos dos eventos ele descobre quem chegou e cumprimenta pelo nome.">
{#snippet acoes()}
<button class="btn pequeno" onclick={() => pessoas.varrer()} disabled={!st?.disponivel || !!st?.varrendo}>
<ScanFace size={14} /> Procurar nas fotos guardadas
</button>
{/snippet}
{#if !st}
<p class="fraco">Carregando…</p>
{:else}
<div class="topo">
<Toggle rotulo="Reconhecer quem chega" checked={st.config.ligado} disabled={!st.disponivel} onchange={(v) => pessoas.ligar(v)} />
{#if !st.disponivel}
<Selo tom="aviso">{st.motivo}</Selo>
{:else if st.varrendo}
<Selo tom="aviso">Procurando rostos: {st.varrendo.feitas} de {st.varrendo.total} fotos</Selo>
{/if}
<p class="privacidade"><ShieldCheck size={15} /> Tudo fica na placa: fotos, rostos e nomes não vão para a internet. Rostos sem nome somem em {st.dias_sem_nome} dias; apagar uma pessoa apaga tudo dela.</p>
</div>
{/if}
</Card>
{#if st}
<Card titulo="Quem ele conhece" descricao={st.pessoas.length ? 'Mais fotos marcadas = reconhece melhor (3 a 5 por pessoa já é bom).' : 'Ninguém ainda. Marque os rostos abaixo para ensinar.'}>
{#if st.pessoas.length}
<ul class="lista">
{#each st.pessoas as p (p.id)}
<li class="pessoa">
<div class="fotos">
{#each p.fotos.slice(0, 4) as f (f.id)}<img src={urlRosto(f.recorte)} alt="" loading="lazy" />{/each}
</div>
<div class="info">
{#if editando === p.id}
<form class="renomear" onsubmit={(e) => { e.preventDefault(); salvarNome(p); }}>
<input bind:value={nomeEditado} maxlength="30" aria-label="Novo nome" />
<button class="btn pequeno" type="submit" aria-label="Salvar nome"><Check size={14} /></button>
<button class="btn pequeno fantasma" type="button" onclick={() => (editando = null)} aria-label="Cancelar"><X size={14} /></button>
</form>
{:else}
<strong>{p.nome}</strong>
{/if}
<small>{quando(p.visto)} · {p.vezes} {p.vezes === 1 ? 'vez' : 'vezes'} · {p.amostras} foto(s) de exemplo</small>
<span class="afinidade" title="Afinidade: cresce com carinho e conversa logo depois de reconhecer">
<span class="barra"><span style:width="{Math.min(100, p.afinidade)}%"></span></span> afinidade {Math.round(p.afinidade)}
</span>
</div>
<div class="botoes">
<button class="btn pequeno fantasma" onclick={() => { editando = p.id; nomeEditado = p.nome; }} aria-label={`Renomear ${p.nome}`}><Pencil size={14} /></button>
{#if apagando === p.id}
<button class="btn pequeno perigo" onclick={() => { pessoas.apagar(p.id); apagando = null; }}>Apagar tudo de {p.nome}</button>
<button class="btn pequeno fantasma" onclick={() => (apagando = null)}>Cancelar</button>
{:else}
<button class="btn pequeno fantasma" onclick={() => (apagando = p.id)} aria-label={`Apagar ${p.nome}`}><Trash2 size={14} /></button>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</Card>
<Card titulo="Rostos sem nome" descricao="Toque nos rostos da mesma pessoa e diga quem é. Foto ruim ou de ninguém: descarte.">
{#if st.sem_nome.length}
{#each st.grupos as ids, g (ids[0])}
{@const todos = ids.every((i) => escolhidos.includes(i))}
<section class="grupo" aria-label="Provavelmente a mesma pessoa">
<header>
<span>{st.grupos.length > 1 ? `Desconhecido ${g + 1}` : 'Desconhecido'} · {ids.length} {ids.length === 1 ? 'foto' : 'fotos'}</span>
<button class="btn pequeno fantasma" onclick={() => (escolhidos = todos ? escolhidos.filter((x) => !ids.includes(x)) : [...new Set([...escolhidos, ...ids])])}>
{todos ? 'Tirar a seleção' : 'Selecionar todas'}
</button>
</header>
<div class="grade" role="group">
{#each st.sem_nome.filter((r) => r.grupo === g) as r (r.id)}
<button class="rosto" aria-pressed={escolhidos.includes(r.id)} onclick={() => alternar(r.id)}
title={new Date(r.horario * 1000).toLocaleString('pt-BR')}>
<img src={urlRosto(r.recorte)} alt="Rosto visto em {new Date(r.horario * 1000).toLocaleString('pt-BR')}" loading="lazy" />
{#if escolhidos.includes(r.id)}<span class="marca"><Check size={14} /></span>{/if}
</button>
{/each}
</div>
</section>
{/each}
{#if escolhidos.length}
<div class="acoes-marcar">
<span>{escolhidos.length} escolhido(s): é…</span>
{#each st.pessoas as p (p.id)}
<button class="btn pequeno" onclick={() => paraPessoa(p)}>{p.nome}</button>
{/each}
<form class="novo" onsubmit={criar}>
<input bind:value={novoNome} placeholder="Nome de alguém novo" maxlength="30" aria-label="Nome de alguém novo" />
<button class="btn pequeno primario" type="submit" disabled={!novoNome.trim()}><UserPlus size={14} /> Criar</button>
</form>
<button class="btn pequeno fantasma" onclick={descartar}><Trash2 size={14} /> Não é ninguém</button>
</div>
{/if}
{:else}
<p class="fraco">Nenhum rosto esperando nome. Eles aparecem aqui quando o sensor vê alguém e a câmera pega o rosto (com luz).</p>
{/if}
</Card>
{/if}
</div>
<style>
.pessoas {
display: grid;
gap: var(--e-4);
}
.topo {
display: grid;
gap: var(--e-2);
}
.privacidade {
display: flex;
gap: var(--e-2);
align-items: flex-start;
margin: 0;
font-size: var(--t-xs);
color: var(--texto-3);
}
.privacidade :global(svg) {
flex: none;
color: var(--ok);
margin-top: 1px;
}
.lista {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--e-2);
}
.pessoa {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--e-3);
align-items: center;
padding: var(--e-3);
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie-2);
}
@media (max-width: 640px) {
.pessoa {
grid-template-columns: minmax(0, 1fr);
}
}
.fotos {
display: flex;
}
.fotos img {
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--superficie);
margin-left: -10px;
background: var(--superficie-3);
}
.fotos img:first-child {
margin-left: 0;
}
.info {
display: grid;
gap: 2px;
min-width: 0;
}
.info small {
color: var(--texto-3);
font-size: var(--t-xs);
}
.afinidade {
display: flex;
gap: var(--e-2);
align-items: center;
font-size: var(--t-xs);
color: var(--texto-3);
}
.barra {
width: 90px;
height: 5px;
border-radius: var(--raio-pill);
background: var(--superficie-3);
overflow: hidden;
}
.barra span {
display: block;
height: 100%;
background: #ff6b9a;
}
.botoes, .renomear, .novo, .acoes-marcar {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
align-items: center;
}
.renomear input, .novo input {
min-width: 0;
width: 180px;
}
.grupo + .grupo {
margin-top: var(--e-4);
}
.grupo header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--e-2);
margin-bottom: var(--e-2);
font-size: var(--t-sm);
color: var(--texto-2);
}
.grade {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(76px, 1fr));
gap: var(--e-2);
}
.rosto {
position: relative;
padding: 0;
border: 2px solid transparent;
border-radius: var(--raio);
background: var(--superficie-3);
overflow: hidden;
cursor: pointer;
aspect-ratio: 1;
}
.rosto img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.rosto[aria-pressed='true'] {
border-color: var(--destaque);
}
.marca {
position: absolute;
top: 4px;
right: 4px;
display: grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--destaque);
color: var(--destaque-texto);
}
.acoes-marcar {
margin-top: var(--e-3);
padding-top: var(--e-3);
border-top: 1px solid var(--borda);
font-size: var(--t-sm);
}
.perigo {
color: var(--perigo);
}
</style>