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
+1 -1
View File
@@ -27,7 +27,7 @@ Set-Location $PSScriptRoot -ErrorAction Stop
# chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai). # chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai).
$arquivos = @( $arquivos = @(
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py', 'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py', 'criacao.py', 'inspiracao.py', 'console.py', 'aprendizado.py', 'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py', 'criacao.py', 'inspiracao.py', 'console.py', 'reconhecimento.py', 'aprendizado.py',
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'voz-robo.js', 'version.json', 'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'voz-robo.js', 'version.json',
'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js' 'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
) )
+11
View File
@@ -40,6 +40,7 @@ class Historico:
self.lock = threading.RLock() self.lock = threading.RLock()
self.publicar = lambda tipo, dados: None self.publicar = lambda tipo, dados: None
self.ao_detectar = lambda tipo: None # o servidor usa para o PIR acordar o rosto self.ao_detectar = lambda tipo: None # o servidor usa para o PIR acordar o rosto
self.ao_foto = lambda ident, caminho: None # foto nova de um evento (reconhecimento de pessoas)
# Modo presença: o servidor liga câmera/acorda (ao_presenca) e desliga/dorme (ao_ausencia). # Modo presença: o servidor liga câmera/acorda (ao_presenca) e desliga/dorme (ao_ausencia).
self.ao_presenca = lambda: None self.ao_presenca = lambda: None
self.ao_ausencia = lambda: None self.ao_ausencia = lambda: None
@@ -298,6 +299,16 @@ class Historico:
self.apagar_arquivo(foto) self.apagar_arquivo(foto)
db.execute('UPDATE eventos SET foto=NULL WHERE id=?', (vid,)) db.execute('UPDATE eventos SET foto=NULL WHERE id=?', (vid,))
self.publicar('historico', dict(foto=ident)) self.publicar('historico', dict(foto=ident))
try:
self.ao_foto(ident, self.fotos / nome)
except Exception:
pass
def fotos_com_evento(self):
"""[(id do evento, arquivo da foto)] das fotos guardadas, mais novas primeiro."""
with self.db() as db:
linhas = db.execute('SELECT id, foto FROM eventos WHERE foto IS NOT NULL ORDER BY id DESC').fetchall()
return [(i, self.fotos / f) for i, f in linhas if (self.fotos / f).is_file()]
# ------------------------------------------------------------------ PIR # ------------------------------------------------------------------ PIR
def vigiar_pir(self): def vigiar_pir(self):
+12 -4
View File
@@ -46,6 +46,7 @@ from criacao import Criacao
from inspiracao import Inspiracao from inspiracao import Inspiracao
from aprendizado import Aprendizado from aprendizado import Aprendizado
from console import Console from console import Console
from reconhecimento import Reconhecimento
TRAVA = threading.Lock() TRAVA = threading.Lock()
@@ -80,7 +81,7 @@ ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador
# Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar. # Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar.
PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"} PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"}
PREFIXOS_API = ("/eventos", "/console", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/aprendizado/", "/criacao/", PREFIXOS_API = ("/eventos", "/console", "/pessoas", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/aprendizado/", "/criacao/",
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/") "/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
@@ -282,7 +283,7 @@ class Handler(SimpleHTTPRequestHandler):
def modulos(self): def modulos(self):
s = self.server s = self.server
return [getattr(s, nome) for nome in ("console", "api_config", "historico", "vida", "aprendizado", "inspiracao", "criacao", "treinamento", "conversa", "cerebro", return [getattr(s, nome) for nome in ("console", "reconhecimento", "api_config", "historico", "vida", "aprendizado", "inspiracao", "criacao", "treinamento", "conversa", "cerebro",
"braco", "servos", "controle") if hasattr(s, nome)] "braco", "servos", "controle") if hasattr(s, nome)]
def estado(self): def estado(self):
@@ -505,7 +506,7 @@ class Servidor(ThreadingHTTPServer):
def ligar_eventos(servidor): def ligar_eventos(servidor):
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida", "criacao", "inspiracao", "aprendizado", for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida", "criacao", "inspiracao", "aprendizado",
"console"): "console", "reconhecimento"):
if hasattr(servidor, nome): if hasattr(servidor, nome):
getattr(servidor, nome).publicar = EVENTOS.publicar getattr(servidor, nome).publicar = EVENTOS.publicar
@@ -581,7 +582,14 @@ def main():
servidor.aprendizado = aprendizado = Aprendizado(Path.cwd() / 'aprendizado.json', nome_criada) servidor.aprendizado = aprendizado = Aprendizado(Path.cwd() / 'aprendizado.json', nome_criada)
# Console: tudo o que ele faz e por quê (cada parte registra o motivo da decisão) # Console: tudo o que ele faz e por quê (cada parte registra o motivo da decisão)
servidor.console = CONSOLE servidor.console = CONSOLE
for parte in (vida, aprendizado, inspiracao, servidor.cerebro, servidor.historico, servidor.controle): # Reconhecimento de pessoas: as fotos dos eventos dizem quem chegou (tudo na placa)
servidor.reconhecimento = rec = Reconhecimento(Path.cwd())
servidor.historico.ao_foto = rec.nova_foto
rec.fotos_guardadas = servidor.historico.fotos_com_evento
rec.ao_reconhecer = vida.reconhecido
vida.reconhece = lambda: rec.config['ligado'] and rec.visao.pronta() and bool(rec.amostras())
vida.gostou_de = rec.gostou
for parte in (vida, aprendizado, inspiracao, servidor.cerebro, servidor.historico, servidor.controle, rec):
parte.console = CONSOLE.registrar parte.console = CONSOLE.registrar
vida.dia_anterior = lambda: aprendizado.historico[-1] if aprendizado.historico else None vida.dia_anterior = lambda: aprendizado.historico[-1] if aprendizado.historico else None
CONSOLE.registrar("sistema", "Servidor iniciado", "versão " + versao()) CONSOLE.registrar("sistema", "Servidor iniciado", "versão " + versao())
+406
View File
@@ -0,0 +1,406 @@
"""Reconhecimento de pessoas: quem é quem, só na placa.
Cada foto de evento (o celular manda uma quando o sensor vê alguém) passa pelo YuNet, que acha os
rostos, e pelo SFace, que vira cada rosto numa "impressão" de 128 números. A impressão é comparada
com as das pessoas cadastradas (semelhança de cosseno); acima do limiar, é aquela pessoa. Rostos sem
nome ficam numa lista para alguém marcar no painel ("esta é a Ana"), e cada marcação vira amostra.
Privacidade: nada sai da placa. Fotos, recortes e impressões ficam em /opt/carinha-pc; apagar uma
pessoa apaga as amostras e os recortes dela; rostos sem nome somem depois de DIAS_SEM_NOME dias.
"""
import json
import os
import queue
import sqlite3
import threading
import time
from contextlib import contextmanager
from pathlib import Path
from comum import gravar_atomico, ler_json
LIMIAR = 0.40 # cosseno mínimo para dizer "é essa pessoa" (o SFace recomenda 0,363)
LIMIAR_GRUPO = 0.45 # rostos sem nome tão parecidos assim vão para o mesmo grupo (marcar de uma vez)
SCORE_ROSTO = 0.80 # confiança mínima do detector para considerar um rosto
TAMANHO_MIN = 48 # px: rosto menor que isso é longe demais para reconhecer bem
DIAS_SEM_NOME = 7 # rostos sem nome guardados para marcar no painel
MAX_AMOSTRAS = 30 # por pessoa (as mais novas)
PADRAO = dict(ligado=True)
MODELOS = ('face_detection_yunet_2023mar.onnx', 'face_recognition_sface_2021dec.onnx')
def cosseno(a, b):
na = sum(x * x for x in a) ** 0.5
nb = sum(x * x for x in b) ** 0.5
if not na or not nb:
return 0.0
return sum(x * y for x, y in zip(a, b)) / (na * nb)
class Visao:
"""YuNet + SFace (OpenCV). Carrega na primeira foto; sem OpenCV ou modelo, fica indisponível."""
def __init__(self, pasta_modelos):
self.pasta = Path(pasta_modelos)
self.det = self.rec = self.cv2 = self.np = None
self.motivo = ''
def pronta(self):
if self.rec:
return True
try:
import cv2
import numpy as np
except ImportError:
self.motivo = 'OpenCV não instalado na placa (apt install python3-opencv)'
return False
faltando = [m for m in MODELOS if not (self.pasta / m).exists()]
if faltando:
self.motivo = 'Faltam os modelos em %s: %s' % (self.pasta, ', '.join(faltando))
return False
self.cv2, self.np = cv2, np
self.det = cv2.FaceDetectorYN.create(str(self.pasta / MODELOS[0]), '', (320, 320), SCORE_ROSTO, 0.3, 5000)
self.rec = cv2.FaceRecognizerSF.create(str(self.pasta / MODELOS[1]), '')
self.motivo = ''
return True
def rostos(self, jpeg):
"""bytes JPEG -> [(vetor[128], recorte_jpeg, score, largura)]"""
cv2, np = self.cv2, self.np
if not jpeg: # foto que não chegou inteira
return []
img = cv2.imdecode(np.frombuffer(jpeg, np.uint8), cv2.IMREAD_COLOR)
if img is None:
return []
h, w = img.shape[:2]
escala = min(1.0, 640 / max(h, w)) # a placa é pequena: analisa em até 640 px
if escala < 1:
img = cv2.resize(img, (int(w * escala), int(h * escala)))
h, w = img.shape[:2]
self.det.setInputSize((w, h))
_, achados = self.det.detect(img)
saida = []
for f in (achados if achados is not None else []):
if f[14] < SCORE_ROSTO or min(f[2], f[3]) < TAMANHO_MIN * escala:
continue
alinhado = self.rec.alignCrop(img, f)
vetor = self.rec.feature(alinhado).flatten().tolist()
ok, recorte = cv2.imencode('.jpg', alinhado, [cv2.IMWRITE_JPEG_QUALITY, 85])
saida.append((vetor, recorte.tobytes() if ok else b'', float(f[14]), float(f[2])))
return saida
class Reconhecimento:
def __init__(self, pasta, visao=None, iniciar=True):
self.pasta = Path(pasta)
self.recortes = self.pasta / 'fotos' / 'rostos'
self.recortes.mkdir(parents=True, exist_ok=True)
self.caminho_db = str(self.pasta / 'pessoas.db')
self.arquivo_config = self.pasta / 'pessoas.json'
self.visao = visao or Visao(self.pasta / 'modelos')
self.lock = threading.RLock()
self.fila = queue.Queue(maxsize=500)
self.publicar = lambda tipo, dados: None
self.console = lambda origem, texto, motivo='', nivel='info', agora=None: None
self.ao_reconhecer = lambda pessoa, confianca, agora: None # o modo vida cumprimenta pelo nome
self.varrendo = None # {feitas, total} ao procurar nas fotos guardadas
self.config = dict(PADRAO)
try:
self.config.update({k: v for k, v in json.loads(self.arquivo_config.read_text(encoding='utf-8')).items()
if k in PADRAO and isinstance(v, bool)})
except (OSError, ValueError, AttributeError):
pass
with self.db() as db:
db.execute('CREATE TABLE IF NOT EXISTS pessoas(id INTEGER PRIMARY KEY, nome TEXT UNIQUE, criado REAL, '
'visto REAL, vezes INTEGER DEFAULT 0, afinidade REAL DEFAULT 0)')
db.execute('CREATE TABLE IF NOT EXISTS rostos(id INTEGER PRIMARY KEY, horario REAL, evento INTEGER, '
'pessoa INTEGER, amostra INTEGER DEFAULT 0, confianca REAL, score REAL, vetor TEXT, recorte TEXT)')
if iniciar:
threading.Thread(target=self.trabalhar, daemon=True).start()
threading.Thread(target=self.faxina, daemon=True).start()
def faxina(self):
while True:
time.sleep(3600)
try:
self.limpar_antigos()
except Exception:
pass
@contextmanager
def db(self):
con = sqlite3.connect(self.caminho_db)
try:
with con:
yield con
finally:
con.close()
# ------------------------------------------------------------ pessoas
def amostras(self):
"""{pessoa: [vetores]} das faces marcadas por alguém."""
with self.db() as db:
linhas = db.execute('SELECT pessoa, vetor FROM rostos WHERE amostra=1 AND pessoa IS NOT NULL').fetchall()
saida = {}
for pessoa, vetor in linhas:
saida.setdefault(pessoa, []).append(json.loads(vetor))
return saida
def quem(self, vetor, amostras=None):
"""(pessoa, confiança) da amostra mais parecida, ou (None, melhor) abaixo do limiar."""
melhor, quem = 0.0, None
for pessoa, vetores in (amostras if amostras is not None else self.amostras()).items():
s = max(cosseno(vetor, v) for v in vetores)
if s > melhor:
melhor, quem = s, pessoa
return (quem, melhor) if melhor >= LIMIAR else (None, melhor)
def pessoa(self, ident):
with self.db() as db:
linha = db.execute('SELECT id, nome, visto, vezes, afinidade FROM pessoas WHERE id=?', (ident,)).fetchone()
return dict(id=linha[0], nome=linha[1], visto=linha[2], vezes=linha[3], afinidade=round(linha[4] or 0, 1)) if linha else None
def pessoa_por_nome(self, nome):
with self.db() as db:
linha = db.execute('SELECT id FROM pessoas WHERE lower(nome)=lower(?)', (nome,)).fetchone()
return self.pessoa(linha[0]) if linha else None
# ------------------------------------------------------------ fotos
def nova_foto(self, evento, caminho):
"""Chamado pelo histórico quando chega a foto de um evento: analisa em segundo plano."""
if self.config['ligado']:
try:
self.fila.put_nowait((evento, str(caminho), False))
except queue.Full:
pass
def analisar(self, evento, jpeg, agora=None, avisar=True):
"""Acha e reconhece os rostos de uma foto. Devolve [(pessoa ou None, confiança)]."""
agora = agora or time.time()
if not self.visao.pronta():
return []
achados = self.visao.rostos(jpeg)
amostras = self.amostras()
resultado = []
for vetor, recorte, score, _ in achados:
pessoa, conf = self.quem(vetor, amostras)
antes = self.pessoa(pessoa)['visto'] if pessoa else None # para a saudade de cada um
with self.lock, self.db() as db:
ident = db.execute('INSERT INTO rostos(horario, evento, pessoa, confianca, score, vetor) VALUES (?,?,?,?,?,?)',
(agora, evento, pessoa, round(conf, 3), round(score, 3), json.dumps([round(x, 5) for x in vetor]))).lastrowid
if recorte:
nome = 'rosto-%d.jpg' % ident
(self.recortes / nome).write_bytes(recorte)
db.execute('UPDATE rostos SET recorte=? WHERE id=?', (nome, ident))
if pessoa:
db.execute('UPDATE pessoas SET visto=?, vezes=vezes+1 WHERE id=?', (agora, pessoa))
resultado.append((pessoa, conf))
if avisar:
if pessoa:
p = dict(self.pessoa(pessoa), visto_antes=antes)
self.console('sentidos', 'Reconheceu %s' % p['nome'], 'semelhança %d%% (evento %s)' % (round(conf * 100), evento),
agora=agora)
try:
self.ao_reconhecer(p, conf, agora)
except Exception:
pass
else:
self.console('sentidos', 'Viu um rosto que ainda não conhece', 'parecido %d%% com alguém (precisa %d%%)' % (
round(conf * 100), round(LIMIAR * 100)), 'detalhe', agora)
if achados and avisar:
self.avisar()
return resultado
def trabalhar(self):
while True:
evento, caminho, varredura = self.fila.get()
try:
jpeg = Path(caminho).read_bytes()
self.analisar(evento, jpeg, avisar=not varredura)
except Exception as e:
print('reconhecimento: erro:', e, flush=True)
finally:
if varredura and self.varrendo:
self.varrendo['feitas'] += 1
if self.varrendo['feitas'] >= self.varrendo['total']:
self.varrendo = None
self.console('sentidos', 'Terminou de procurar rostos nas fotos guardadas')
self.avisar()
def varrer(self, eventos):
"""Procura rostos nas fotos já guardadas (para cadastrar pessoas logo de cara)."""
if not self.visao.pronta():
raise ValueError(self.visao.motivo)
if self.varrendo:
raise ValueError('Já estou procurando nas fotos guardadas')
with self.db() as db:
feitos = {r[0] for r in db.execute('SELECT DISTINCT evento FROM rostos')}
alvos = [(e, c) for e, c in eventos if e not in feitos and c]
if not alvos:
raise ValueError('Nenhuma foto nova para analisar')
self.varrendo = dict(feitas=0, total=len(alvos))
for evento, caminho in alvos:
self.fila.put((evento, str(caminho), True))
self.avisar()
return self.status()
# ------------------------------------------------------------ marcar no painel
def marcar(self, rostos, pessoa=None, nome=None):
"""Diz de quem são estes rostos (vira amostra). nome cria a pessoa; sem pessoa e nome, descarta."""
if not isinstance(rostos, list) or not rostos or not all(type(r) is int for r in rostos):
raise ValueError('Escolha pelo menos um rosto')
with self.lock, self.db() as db:
if nome is not None:
nome = ' '.join(str(nome).split())[:30]
if not nome:
raise ValueError('Digite o nome')
existente = db.execute('SELECT id FROM pessoas WHERE lower(nome)=lower(?)', (nome,)).fetchone()
pessoa = existente[0] if existente else db.execute(
'INSERT INTO pessoas(nome, criado) VALUES (?,?)', (nome, time.time())).lastrowid
elif pessoa is not None and not db.execute('SELECT 1 FROM pessoas WHERE id=?', (pessoa,)).fetchone():
raise ValueError('Pessoa não encontrada')
marcas = ','.join('?' * len(rostos))
if pessoa is None: # não é ninguém (ou foto ruim): some da lista
for (arquivo,) in db.execute('SELECT recorte FROM rostos WHERE id IN (%s)' % marcas, rostos).fetchall():
self.apagar_recorte(arquivo)
db.execute('DELETE FROM rostos WHERE id IN (%s)' % marcas, rostos)
else:
db.execute('UPDATE rostos SET pessoa=?, amostra=1 WHERE id IN (%s)' % marcas, [pessoa] + rostos)
# guarda só as amostras mais novas de cada pessoa
velhas = db.execute('SELECT id, recorte FROM rostos WHERE pessoa=? AND amostra=1 ORDER BY id DESC LIMIT -1 OFFSET ?',
(pessoa, MAX_AMOSTRAS)).fetchall()
for vid, arquivo in velhas:
self.apagar_recorte(arquivo)
db.execute('DELETE FROM rostos WHERE id=?', (vid,))
if pessoa is not None:
self.reconferir()
p = self.pessoa(pessoa)
self.console('sentidos', 'Aprendeu o rosto de %s' % p['nome'], '%d foto(s) marcada(s) no painel' % len(rostos))
self.avisar()
return self.status()
def reconferir(self):
"""Com amostras novas, os rostos sem nome são comparados de novo (sem reabrir as fotos)."""
amostras = self.amostras()
if not amostras:
return
with self.lock, self.db() as db:
for ident, vetor in db.execute('SELECT id, vetor FROM rostos WHERE pessoa IS NULL').fetchall():
pessoa, conf = self.quem(json.loads(vetor), amostras)
if pessoa:
db.execute('UPDATE rostos SET pessoa=?, confianca=? WHERE id=?', (pessoa, round(conf, 3), ident))
def renomear(self, pessoa, nome):
nome = ' '.join(str(nome or '').split())[:30]
if not nome:
raise ValueError('Digite o nome')
with self.db() as db:
try:
db.execute('UPDATE pessoas SET nome=? WHERE id=?', (nome, pessoa))
except sqlite3.IntegrityError:
raise ValueError('Já existe alguém com esse nome') from None
self.avisar()
return self.status()
def apagar_pessoa(self, pessoa):
with self.lock, self.db() as db:
for (arquivo,) in db.execute('SELECT recorte FROM rostos WHERE pessoa=?', (pessoa,)).fetchall():
self.apagar_recorte(arquivo)
db.execute('DELETE FROM rostos WHERE pessoa=?', (pessoa,))
db.execute('DELETE FROM pessoas WHERE id=?', (pessoa,))
self.avisar()
return self.status()
def gostou(self, pessoa, quanto=1.0):
"""Carinho ou conversa de quem foi reconhecido há pouco: a afinidade com essa pessoa cresce."""
with self.db() as db:
db.execute('UPDATE pessoas SET afinidade=min(100, afinidade+?) WHERE id=?', (quanto, pessoa))
def apagar_recorte(self, arquivo):
if arquivo:
(self.recortes / arquivo).unlink(missing_ok=True)
def limpar_antigos(self, agora=None):
limite = (agora or time.time()) - DIAS_SEM_NOME * 86400
with self.lock, self.db() as db:
velhos = db.execute('SELECT id, recorte FROM rostos WHERE amostra=0 AND horario < ?', (limite,)).fetchall()
for vid, arquivo in velhos:
self.apagar_recorte(arquivo)
db.execute('DELETE FROM rostos WHERE amostra=0 AND horario < ?', (limite,))
return len(velhos)
def configurar(self, dados):
if not isinstance(dados, dict) or set(dados) - set(PADRAO) or not all(isinstance(v, bool) for v in dados.values()):
raise ValueError('Ajuste inválido')
self.config.update(dados)
gravar_atomico(self.arquivo_config, self.config)
self.avisar()
return self.status()
# ------------------------------------------------------------ painel
def status(self):
pronta = self.visao.pronta()
with self.db() as db:
pessoas = []
for pid, nome, visto, vezes, afinidade in db.execute('SELECT id, nome, visto, vezes, afinidade FROM pessoas ORDER BY nome'):
fotos = [dict(id=i, recorte=r) for i, r in db.execute(
'SELECT id, recorte FROM rostos WHERE pessoa=? AND recorte IS NOT NULL ORDER BY amostra DESC, id DESC LIMIT 6', (pid,))]
amostras = db.execute('SELECT count(*) FROM rostos WHERE pessoa=? AND amostra=1', (pid,)).fetchone()[0]
pessoas.append(dict(id=pid, nome=nome, visto=visto, vezes=vezes, afinidade=round(afinidade or 0, 1),
amostras=amostras, fotos=fotos))
linhas = db.execute('SELECT id, horario, evento, recorte, confianca, vetor FROM rostos WHERE pessoa IS NULL '
'AND recorte IS NOT NULL ORDER BY id DESC LIMIT 120').fetchall()
total = db.execute('SELECT count(*) FROM rostos').fetchone()[0]
sem_nome, grupos = [], [] # grupos: rostos parecidos entre si (provável mesma pessoa)
for i, hr, e, r, c, vetor in linhas:
v = json.loads(vetor)
g = next((g for g in grupos if cosseno(v, g['v']) >= LIMIAR_GRUPO), None)
if g is None:
g = dict(v=v, ids=[])
grupos.append(g)
g['ids'].append(i)
sem_nome.append(dict(id=i, horario=hr, evento=e, recorte=r, parecido=c, grupo=grupos.index(g)))
grupos = [g['ids'] for g in grupos]
return dict(config=dict(self.config), disponivel=pronta, motivo='' if pronta else self.visao.motivo,
pessoas=pessoas, sem_nome=sem_nome, grupos=grupos, rostos=total, varrendo=self.varrendo, limiar=LIMIAR,
dias_sem_nome=DIAS_SEM_NOME)
def avisar(self):
self.publicar('pessoas', self.status())
def rotas(self, h, metodo):
rota = h.path.split('?', 1)[0]
if not rota.startswith('/pessoas'):
return False
try:
if metodo == 'GET' and rota == '/pessoas':
resultado = self.status()
elif metodo == 'GET' and rota.startswith('/pessoas/rosto/'):
nome = os.path.basename(rota)
arquivo = self.recortes / nome
if not nome.startswith('rosto-') or not nome.endswith('.jpg') or not arquivo.is_file():
raise ValueError('Rosto não encontrado')
h.enviar(arquivo.read_bytes(), 'image/jpeg')
return True
elif metodo == 'POST':
d = ler_json(h, 8192)
if rota == '/pessoas/marcar':
resultado = self.marcar(d.get('rostos'), d.get('pessoa'), d.get('nome'))
elif rota == '/pessoas/renomear':
resultado = self.renomear(d.get('pessoa'), d.get('nome'))
elif rota == '/pessoas/apagar':
resultado = self.apagar_pessoa(d.get('pessoa'))
elif rota == '/pessoas/config':
resultado = self.configurar(d)
elif rota == '/pessoas/varrer':
resultado = self.varrer(self.fotos_guardadas())
else:
raise ValueError('Rota inválida')
else:
raise ValueError('Método inválido')
h.enviar(json.dumps(resultado, ensure_ascii=False))
except (ValueError, TypeError) as e:
h.enviar(json.dumps(dict(erro=str(e)), ensure_ascii=False), codigo=400)
return True
fotos_guardadas = staticmethod(lambda: []) # o main liga ao histórico
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# Reconhecimento de pessoas na placa: OpenCV + modelos YuNet (acha rostos) e SFace (impressão do rosto).
# Rodar como root. Tudo fica na placa; nada vai para a internet depois de baixar os modelos.
set -e
DESTINO=${1:-/opt/carinha-pc}
apt-get install -y --no-install-recommends python3-opencv
mkdir -p "$DESTINO/modelos"
cd "$DESTINO/modelos"
for m in face_detection_yunet/face_detection_yunet_2023mar.onnx face_recognition_sface/face_recognition_sface_2021dec.onnx; do
[ -s "$(basename "$m")" ] || curl -fsSL -o "$(basename "$m")" "https://github.com/opencv/opencv_zoo/raw/main/models/$m"
done
python3 -c "import cv2; assert hasattr(cv2, 'FaceRecognizerSF'); print('reconhecimento pronto: OpenCV', cv2.__version__)"
systemctl restart carinha-pc
+177
View File
@@ -0,0 +1,177 @@
"""Reconhecimento de pessoas: marcar rostos, reconhecer, saudade por pessoa, afinidade e privacidade."""
import random
import sys
import tempfile
import time
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from reconhecimento import DIAS_SEM_NOME, Reconhecimento, cosseno # noqa: E402
from vida import Vida # noqa: E402
DIA = time.mktime((2026, 10, 12, 15, 0, 0, 0, 0, -1))
def rosto_de(semente, ruido=0.25):
"""Impressão de 128 números: a mesma pessoa varia pouco, pessoas diferentes são bem diferentes."""
base = random.Random(semente)
r = random.Random()
return [base.gauss(0, 1) + r.gauss(0, ruido) for _ in range(128)]
class VisaoFalsa:
motivo = ''
def __init__(self):
self.fotos = {}
def pronta(self):
return True
def rostos(self, jpeg):
return [(v, b'\xff\xd8recorte\xff\xd9', 0.95, 120) for v in self.fotos.get(jpeg, [])]
class ReconhecimentoTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.visao = VisaoFalsa()
self.r = Reconhecimento(self.tmp.name, visao=self.visao, iniciar=False)
self.vistos, self.linhas = [], []
self.r.ao_reconhecer = lambda p, conf, agora: self.vistos.append((p['nome'], round(conf, 2), p.get('visto_antes')))
self.r.console = lambda origem, texto, motivo='', nivel='info', agora=None: self.linhas.append(texto)
def tearDown(self):
self.tmp.cleanup()
def foto(self, evento, *pessoas, agora=DIA):
jpeg = ('foto-%d' % evento).encode()
self.visao.fotos[jpeg] = [rosto_de(p) for p in pessoas]
return self.r.analisar(evento, jpeg, agora)
def test_impressoes_da_mesma_pessoa_se_parecem(self):
self.assertGreater(cosseno(rosto_de('ana'), rosto_de('ana')), 0.8)
self.assertLess(abs(cosseno(rosto_de('ana'), rosto_de('bia'))), 0.3)
def test_marcar_e_depois_reconhecer_pelo_nome(self):
(pessoa, _), = self.foto(1, 'ana') # ninguém cadastrado: rosto sem nome
self.assertIsNone(pessoa)
self.foto(2, 'ana'); self.foto(3, 'ana')
sem_nome = [s['id'] for s in self.r.status()['sem_nome']]
self.assertEqual(len(sem_nome), 3)
self.r.marcar(sem_nome, nome='Ana')
st = self.r.status()
self.assertEqual([p['nome'] for p in st['pessoas']], ['Ana'])
self.assertEqual(st['pessoas'][0]['amostras'], 3)
self.assertEqual(st['sem_nome'], [])
# foto nova: reconhece a Ana e não a Bia
(pessoa, conf), = self.foto(4, 'ana', agora=DIA + 60)
self.assertIsNotNone(pessoa)
self.assertEqual(self.vistos[-1][0], 'Ana')
self.assertIn('Reconheceu Ana', self.linhas)
(pessoa, _), = self.foto(5, 'bia', agora=DIA + 120)
self.assertIsNone(pessoa)
self.assertEqual(len(self.r.status()['sem_nome']), 1)
def test_marcar_a_pessoa_reconhece_os_rostos_antigos_sem_nome(self):
for i in range(4):
self.foto(10 + i, 'bia')
ids = [s['id'] for s in self.r.status()['sem_nome']]
self.r.marcar(ids[:1], nome='Bia') # marcou um: os outros parecidos já passam a ser dela
st = self.r.status()
self.assertEqual(st['sem_nome'], [])
self.assertEqual(st['pessoas'][0]['amostras'], 1)
def test_saudade_conta_quando_cada_um_foi_visto(self):
self.foto(1, 'ana')
self.r.marcar([s['id'] for s in self.r.status()['sem_nome']], nome='Ana')
self.foto(2, 'ana', agora=DIA + 100)
self.foto(3, 'ana', agora=DIA + 5000)
self.assertEqual(self.vistos[-1][2], DIA + 100) # visto antes desta foto
def test_rostos_parecidos_ficam_no_mesmo_grupo(self):
self.foto(1, 'ana'); self.foto(2, 'bia'); self.foto(3, 'ana'); self.foto(4, 'ana')
grupos = sorted(len(g) for g in self.r.status()['grupos'])
self.assertEqual(grupos, [1, 3])
def test_descartar_renomear_apagar(self):
self.foto(1, 'ana'); self.foto(2, 'gato')
ana, gato = sorted(s['id'] for s in self.r.status()['sem_nome'])
self.r.marcar([gato]) # não é ninguém
self.r.marcar([ana], nome='ana')
self.r.marcar([ana], nome='ANA') # mesmo nome: mesma pessoa
pid = self.r.status()['pessoas'][0]['id']
self.r.renomear(pid, 'Ana Paula')
self.assertEqual(self.r.status()['pessoas'][0]['nome'], 'Ana Paula')
self.assertEqual(len(list((Path(self.tmp.name) / 'fotos' / 'rostos').glob('*.jpg'))), 1)
self.r.apagar_pessoa(pid) # apaga a pessoa e tudo dela
self.assertEqual(self.r.status()['pessoas'], [])
self.assertEqual(self.r.status()['rostos'], 0)
self.assertEqual(list((Path(self.tmp.name) / 'fotos' / 'rostos').glob('*.jpg')), [])
with self.assertRaises(ValueError):
self.r.marcar([], nome='x')
def test_rostos_sem_nome_somem_depois_de_uns_dias(self):
self.foto(1, 'ana', agora=DIA)
self.foto(2, 'bia', agora=DIA)
self.r.marcar([self.r.status()['sem_nome'][0]['id']], nome='Bia')
self.assertEqual(self.r.limpar_antigos(DIA + DIAS_SEM_NOME * 86400 + 1), 1) # a amostra da Bia fica
self.assertEqual(self.r.status()['rostos'], 1)
def test_desligado_nao_analisa_fotos_novas(self):
self.r.configurar(dict(ligado=False))
self.r.nova_foto(1, 'x.jpg')
self.assertTrue(self.r.fila.empty())
with self.assertRaises(ValueError):
self.r.configurar(dict(outra=True))
class VidaComNomesTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.v = Vida(Path(self.tmp.name) / 'vida.json', iniciar=False, rng=random.Random(1))
self.ordens, self.afinidade = [], []
self.v.enfileirar = self.ordens.append
self.v.celular_online = lambda: True
self.v.celular_dormindo = lambda: False
self.v.madrugada = lambda agora: False
self.v.reconhece = lambda: True
self.v.gostou_de = lambda pessoa, quanto=1.0: self.afinidade.append((pessoa, quanto))
self.v.saudou_dia = '2026-10-12'
self.perto = False
self.v.alguem_perto = lambda: self.perto
def tearDown(self):
self.tmp.cleanup()
def falas(self):
return [o[6:] for o in self.ordens if o.startswith('dizer ')]
def test_espera_saber_quem_chegou_para_cumprimentar_pelo_nome(self):
self.v.ultimo_perto, self.perto = DIA - 2 * 3600, True
self.v.tick(DIA)
self.assertEqual(self.falas(), []) # esperando a foto
self.v.reconhecido(dict(id=1, nome='Ana', afinidade=25, visto_antes=DIA - 6 * 3600), 0.8, DIA + 3)
self.assertEqual(self.falas(), ['Ana! Senti sua falta!']) # 6 h sem a Ana, mesmo a casa tendo gente há 2 h
self.assertIsNone(self.v.reencontro_pendente)
def test_sem_reconhecer_cumprimenta_sem_nome_depois_de_esperar(self):
self.v.ultimo_perto, self.perto = DIA - 2 * 3600, True
self.v.tick(DIA)
self.v.tick(DIA + 5)
self.assertEqual(self.falas(), [])
self.v.tick(DIA + 9)
self.assertEqual(self.falas(), ['Oi! Você voltou!'])
def test_outra_pessoa_chega_com_alguem_ja_por_perto(self):
self.v.reconhecido(dict(id=2, nome='Bia', afinidade=0, visto_antes=DIA - 3 * 3600), 0.7, DIA)
self.assertEqual(self.falas(), ['Oi, Bia! Você voltou!'])
self.v.reconhecido(dict(id=2, nome='Bia', afinidade=0, visto_antes=DIA), 0.7, DIA + 60) # acabou de ver
self.assertEqual(len(self.falas()), 1)
self.v.estimulo('carinho', DIA + 90) # carinho logo depois: afinidade com a Bia
self.assertEqual(self.afinidade, [(2, 1.0)])
if __name__ == '__main__':
unittest.main()
+49 -9
View File
@@ -124,6 +124,8 @@ GESTOS_TRACO = {'brincadeira': {'rir', 'comemorar', 'alegre', 'musica', 'piscadi
AUSENCIA_ACENO, AUSENCIA_OI, AUSENCIA_SAUDADE = 10 * 60, 60 * 60, 4 * 3600 AUSENCIA_ACENO, AUSENCIA_OI, AUSENCIA_SAUDADE = 10 * 60, 60 * 60, 4 * 3600
INTERVALO_PEDIDO = 20 * 60 # no mínimo 20 min entre um pedido ("brinca comigo?") e outro INTERVALO_PEDIDO = 20 * 60 # no mínimo 20 min entre um pedido ("brinca comigo?") e outro
DESISTE = 0.12 # chance de largar uma atividade no meio porque ficou chato DESISTE = 0.12 # chance de largar uma atividade no meio porque ficou chato
ESPERA_NOME = 8 # s esperando o reconhecimento antes de cumprimentar sem o nome
OI_DE_NOVO = 10 * 60 # reconheceu alguém que não via há esse tempo: "Oi, Ana!"
GESTOS_VIDA = {g for atos in ATOS.values() for g, _, _ in atos} GESTOS_VIDA = {g for atos in ATOS.values() for g, _, _ in atos}
@@ -172,6 +174,11 @@ class Vida:
self.pediu_em = 0.0 self.pediu_em = 0.0
self.desistencia = None # (quando, o que) se largou a atividade no meio self.desistencia = None # (quando, o que) se largou a atividade no meio
self.figurinha_mostrada = 0.0 self.figurinha_mostrada = 0.0
# reconhecimento de pessoas (reconhecimento.py): o main liga
self.reconhece = lambda: False # True: vale esperar a foto para cumprimentar pelo nome
self.gostou_de = lambda pessoa, quanto=1.0: None
self.reencontro_pendente = None # (desde, ausência) esperando saber quem chegou
self.ultimo_reconhecido = None # (id, nome, quando)
agora = time.time() agora = time.time()
self.config = dict(PADRAO) self.config = dict(PADRAO)
@@ -359,6 +366,9 @@ class Vida:
if self.aprendizado: if self.aprendizado:
self.aprendizado.resposta(tipo, agora) self.aprendizado.resposta(tipo, agora)
self.amadurecer(tipo) self.amadurecer(tipo)
rec = self.ultimo_reconhecido
if rec and agora - rec[2] < 120:
self.gostou_de(rec[0], 1.0 if tipo in ('carinho', 'brincar', 'conversa') else 0.5)
self.console('sentidos', CUIDADOS.get(tipo) or NOME_ESTIMULO.get(tipo, tipo), self.console('sentidos', CUIDADOS.get(tipo) or NOME_ESTIMULO.get(tipo, tipo),
', '.join('%s %+d' % (k, v) for k, v in efeito.items()), ', '.join('%s %+d' % (k, v) for k, v in efeito.items()),
'detalhe' if tipo in ('som', 'movimento') else 'info', agora) 'detalhe' if tipo in ('som', 'movimento') else 'info', agora)
@@ -453,10 +463,12 @@ class Vida:
def tempo(seg): def tempo(seg):
return '%d min' % round(seg / 60) if seg < 3600 else '%dh%02d' % (seg // 3600, seg % 3600 // 60) return '%d min' % round(seg / 60) if seg < 3600 else '%dh%02d' % (seg // 3600, seg % 3600 // 60)
def saudacao(self, agora): def saudacao(self, agora, nome=None):
"""Bom dia com uma lembrança de ontem (do que ele viveu, sem IA).""" """Bom dia com uma lembrança de ontem (do que ele viveu, sem IA)."""
hora = time.localtime(agora).tm_hour hora = time.localtime(agora).tm_hour
prefixo = 'Bom dia' if 5 <= hora < 12 else 'Boa tarde' if hora < 18 else 'Boa noite' prefixo = 'Bom dia' if 5 <= hora < 12 else 'Boa tarde' if hora < 18 else 'Boa noite'
if nome:
prefixo += ', ' + nome
fatos = [] fatos = []
ontem = None ontem = None
try: try:
@@ -478,29 +490,35 @@ class Vida:
fatos.append('Acordei cheio de energia!' if self.necessidades['energia'] >= 80 else 'Que bom te ver!') fatos.append('Acordei cheio de energia!' if self.necessidades['energia'] >= 80 else 'Que bom te ver!')
return '%s! %s' % (prefixo, self.acaso.choice(fatos)) return '%s! %s' % (prefixo, self.acaso.choice(fatos))
def reencontro(self, agora, ausencia): def reencontro(self, agora, ausencia, nome=None, afinidade=0.0):
"""Alguém apareceu: a reação depende de quanto tempo ficou fora (e de quem ele é).""" """Alguém apareceu: a reação depende de quanto tempo ficou fora (e de quem ele é)."""
if self.dormindo() or self.madrugada(agora) or self.bloqueado(): if self.dormindo() or self.madrugada(agora) or self.bloqueado():
return None return None
n, tim = self.necessidades, self.personalidade['timidez'] n, tim = self.necessidades, self.personalidade['timidez']
dia = time.strftime('%Y-%m-%d', time.localtime(agora)) dia = time.strftime('%Y-%m-%d', time.localtime(agora))
motivo = 'ninguém por perto havia ' + self.tempo(ausencia) motivo = ('%s não aparecia havia ' % nome if nome else 'ninguém por perto havia ') + self.tempo(ausencia)
querido = afinidade >= 20 # muito carinho dessa pessoa: festa com coração
if self.saudou_dia != dia and time.localtime(agora).tm_hour >= 5 and ausencia >= AUSENCIA_ACENO: if self.saudou_dia != dia and time.localtime(agora).tm_hour >= 5 and ausencia >= AUSENCIA_ACENO:
self.saudou_dia = dia self.saudou_dia = dia
frase = self.saudacao(agora) frase = self.saudacao(agora, nome)
self.dizer(frase, 'timido' if tim > 0.65 else 'acenar') self.dizer(frase, 'timido' if tim > 0.65 else 'acenar')
ganho, texto, motivo = 10, 'Deu bom dia: "%s"' % frase, 'primeiro encontro do dia; ' + motivo ganho, texto, motivo = 10, 'Deu bom dia: "%s"' % frase, 'primeiro encontro do dia; ' + motivo
elif ausencia >= AUSENCIA_SAUDADE: elif ausencia >= AUSENCIA_SAUDADE:
self.dizer('Senti sua falta!', 'comemorar') self.dizer('%s! Senti sua falta!' % nome if nome else 'Senti sua falta!', 'comemorar')
self.enfileirar('coracao') self.enfileirar('coracao')
ganho, texto = 20, 'Fez festa: estava com saudade' ganho, texto = 20, 'Fez festa: estava com saudade' + (' de ' + nome if nome else '')
n['alegria'] = _limitar(n['alegria'] + 10) n['alegria'] = _limitar(n['alegria'] + 10)
elif ausencia >= AUSENCIA_OI: elif ausencia >= AUSENCIA_OI:
self.dizer('Oi! Você voltou!', 'timido' if tim > 0.65 else 'comemorar') self.dizer('Oi, %s! Você voltou!' % nome if nome else 'Oi! Você voltou!', 'timido' if tim > 0.65 else 'comemorar')
ganho, texto = 10, 'Comemorou que alguém voltou' if querido:
self.enfileirar('coracao')
ganho, texto = 10, 'Comemorou que %s voltou' % (nome or 'alguém')
elif ausencia >= AUSENCIA_ACENO: elif ausencia >= AUSENCIA_ACENO:
if nome:
self.dizer('Oi, %s!' % nome, 'acenar')
else:
self.enfileirar('acenar') self.enfileirar('acenar')
ganho, texto = 4, 'Acenou para quem voltou' ganho, texto = 4, 'Acenou para %s' % (nome or 'quem voltou')
else: else:
return None return None
with self.lock: with self.lock:
@@ -510,6 +528,21 @@ class Vida:
self.avisar() self.avisar()
return texto return texto
def reconhecido(self, pessoa, confianca, agora=None):
"""O reconhecimento disse quem está aí (vem da thread do reconhecimento)."""
agora = agora or time.time()
nome = pessoa['nome']
self.ultimo_reconhecido = (pessoa['id'], nome, agora)
antes = pessoa.get('visto_antes')
pendente = self.reencontro_pendente
if pendente: # chegou alguém e agora sabe quem é
self.reencontro_pendente = None
ausencia = agora - antes if antes else pendente[1]
return self.reencontro(agora, ausencia, nome, pessoa.get('afinidade', 0))
if antes and agora - antes > OI_DE_NOVO: # chegou outra pessoa com alguém já por perto
return self.reencontro(agora, agora - antes, nome, pessoa.get('afinidade', 0))
return None
def pedir_algo(self, agora): def pedir_algo(self, agora):
"""Iniciativa com motivo: pede o que está faltando (ou mostra o que fez).""" """Iniciativa com motivo: pede o que está faltando (ou mostra o que fez)."""
n = self.necessidades n = self.necessidades
@@ -769,9 +802,16 @@ class Vida:
perto = self.alguem_perto() perto = self.alguem_perto()
if perto: if perto:
if not self.perto_antes: if not self.perto_antes:
if self.reconhece():
self.reencontro_pendente = (agora, agora - self.ultimo_perto) # espera a foto dizer quem é
else:
self.reencontro(agora, agora - self.ultimo_perto) self.reencontro(agora, agora - self.ultimo_perto)
self.ultimo_perto = agora self.ultimo_perto = agora
self.perto_antes = perto self.perto_antes = perto
pendente = self.reencontro_pendente
if pendente and agora - pendente[0] >= ESPERA_NOME:
self.reencontro_pendente = None
self.reencontro(agora, pendente[1])
if self.desistencia and agora >= self.desistencia[0]: if self.desistencia and agora >= self.desistencia[0]:
_, oque = self.desistencia _, oque = self.desistencia
self.desistencia = None self.desistencia = None
+4 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <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 { onMount, type Component } from 'svelte';
import Avisos from './lib/componentes/Avisos.svelte'; import Avisos from './lib/componentes/Avisos.svelte';
import EStop from './lib/componentes/EStop.svelte'; import EStop from './lib/componentes/EStop.svelte';
@@ -22,6 +22,7 @@
import Criar from './telas/Criar.svelte'; import Criar from './telas/Criar.svelte';
import DiaADia from './telas/DiaADia.svelte'; import DiaADia from './telas/DiaADia.svelte';
import Console from './telas/Console.svelte'; import Console from './telas/Console.svelte';
import Pessoas from './telas/Pessoas.svelte';
import Ajuda from './lib/componentes/Ajuda.svelte'; import Ajuda from './lib/componentes/Ajuda.svelte';
import { TOPICO_DA_TELA } from './lib/guia'; import { TOPICO_DA_TELA } from './lib/guia';
@@ -37,11 +38,13 @@
criar: Criar, criar: Criar,
'dia-a-dia': DiaADia, 'dia-a-dia': DiaADia,
console: Console, console: Console,
pessoas: Pessoas,
}; };
const ICONES: Record<string, Component<any>> = { const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video, 'ao-vivo': Video,
eventos: Bell, eventos: Bell,
console: Terminal, console: Terminal,
pessoas: Users,
conversa: MessageCircle, conversa: MessageCircle,
reacoes: Smile, reacoes: Smile,
movimento: Hand, movimento: Hand,
+1 -1
View File
@@ -188,7 +188,7 @@ function abrirEventos() {
if (estado.conexao !== 'token') estado.conexao = 'caiu'; if (estado.conexao !== 'token') estado.conexao = 'caiu';
}; };
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia', 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) => { fonte.addEventListener(tipo, (e) => {
const dados = JSON.parse((e as MessageEvent).data); const dados = JSON.parse((e as MessageEvent).data);
tratadores[tipo]?.(dados); 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.' }, { 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: { jeito: {
titulo: 'Jeito de gente', titulo: 'Jeito de gente',
emoji: '🧒', emoji: '🧒',
@@ -402,13 +416,13 @@ export const TOPICOS: Record<string, TopicoGuia> = {
/** Ordem do tutorial (página Guia). */ /** Ordem do tutorial (página Guia). */
export const ORDEM_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', 'personalidade', 'acontecimentos', 'ligacoes', 'eventos', 'presenca', 'tela', 'sensores', 'sensibilidade',
'reacoes', 'conversa', 'movimento', 'atalhos', 'reacoes', 'conversa', 'movimento', 'atalhos',
]; ];
/** Tópico de cada tela, para o ⓘ do topo. */ /** Tópico de cada tela, para o ⓘ do topo. */
export const TOPICO_DA_TELA: Record<string, string> = { 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', 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 "/". */ /** 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 }[] = [ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' }, { id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
{ id: 'console', titulo: 'Console', atalho: 'g k' }, { id: 'console', titulo: 'Console', atalho: 'g k' },
{ id: 'eventos', titulo: 'Eventos', atalho: 'g e' }, { id: 'eventos', titulo: 'Eventos', atalho: 'g e' },
{ id: 'pessoas', titulo: 'Pessoas', atalho: 'g p' },
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' }, { id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' }, { id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
{ id: 'movimento', titulo: 'Movimento', atalho: 'g m' }, { 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>