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:
+1
-1
@@ -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'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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:
|
||||||
self.enfileirar('acenar')
|
if nome:
|
||||||
ganho, texto = 4, 'Acenou para quem voltou'
|
self.dizer('Oi, %s!' % nome, 'acenar')
|
||||||
|
else:
|
||||||
|
self.enfileirar('acenar')
|
||||||
|
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:
|
||||||
self.reencontro(agora, agora - self.ultimo_perto)
|
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.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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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));
|
||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user