Como construir busca de fotos por reconhecimento facial
Eventos geram centenas de fotos. Vasculhar um álbum enorme para encontrar a sua própria imagem é frustrante, e organizar isso manualmente não escala. A solução: o participante tira uma selfie, e o sistema encontra automaticamente todas as fotos onde ele aparece.
Esse tutorial mostra como construir isso do zero com Python, sem precisar de GPU.
A ideia em três partes
O sistema tem uma arquitetura simples:
- Armazenamento de fotos: as imagens do evento ficam em algum object storage (AWS S3, Cloudflare R2, Google Cloud Storage, MinIO local, qualquer um com acesso via API)
- Banco vetorial: um PostgreSQL com a extensão pgvector guarda as representações faciais extraídas de cada foto
- API: dois endpoints, um para indexar as fotos e outro para fazer a busca
Pré-requisitos
pip install fastapi insightface opencv-python psycopg2-binary boto3
Você vai precisar também de:
- PostgreSQL com a extensão pgvector instalada (
CREATE EXTENSION vector;) - Acesso a algum object storage com as fotos do evento
Passo 1: configurar o InsightFace
O InsightFace é uma biblioteca open-source de reconhecimento facial. O modelo buffalo_l gera embeddings de 512 dimensões por rosto detectado, funciona bem em CPU e é o ponto de partida ideal.
from insightface.app import FaceAnalysis face_app = FaceAnalysis(name="buffalo_l", providers=["CPUExecutionProvider"]) face_app.prepare(ctx_id=-1, det_size=(640, 640))
O ctx_id=-1 força o uso de CPU. O det_size=(640, 640) equilibra precisão e velocidade: tamanhos maiores detectam rostos menores na imagem, mas ficam mais lentos. Para eventos com boa iluminação, 640x640 é suficiente.
A função de extração do embedding é direta:
import cv2 import numpy as np def compute_embedding(image_bytes: bytes): img_array = np.frombuffer(image_bytes, np.uint8) img = cv2.imdecode(img_array, cv2.IMREAD_COLOR) faces = face_app.get(img) if not faces: return None return faces[0].normed_embedding # vetor normalizado, pronto para distância coseno
O embedding retornado já vem normalizado, o que é necessário para a busca por similaridade coseno funcionar corretamente.
Passo 2: criar a tabela no banco
Com o pgvector instalado no PostgreSQL, crie a tabela que vai guardar os embeddings:
CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE face_embeddings ( id SERIAL PRIMARY KEY, key TEXT UNIQUE NOT NULL, -- identificador da foto (path no storage) embedding vector(512) NOT NULL, metadata JSONB -- dados extras opcionais (nome do arquivo, data, etc.) ); -- índice para acelerar a busca CREATE INDEX ON face_embeddings USING ivfflat (embedding vector_cosine_ops);
O campo key guarda o identificador da foto no seu storage, seja o path no S3 ou qualquer outra referência que permita gerar a URL depois.
Passo 3: o endpoint de indexação (/sync)
Esse endpoint percorre o seu storage, baixa cada imagem, extrai o embedding e persiste no banco. A lógica de pular fotos já processadas é importante: torna o endpoint idempotente, então você pode rodar várias vezes sem duplicar dados.
import boto3 import psycopg2 from fastapi import FastAPI app = FastAPI() # configure conforme seu storage s3 = boto3.client( "s3", endpoint_url="https://seu-endpoint", # omita se for AWS S3 padrão aws_access_key_id="ACCESS_KEY", aws_secret_access_key="SECRET_KEY", ) BUCKET = "nome-do-bucket" @app.post("/sync") def sync(): conn = psycopg2.connect("postgresql://user:pass@host/db") cur = conn.cursor() indexed = 0 paginator = s3.get_paginator("list_objects_v2") for page in paginator.paginate(Bucket=BUCKET): for obj in page.get("Contents", []): key = obj["Key"] if not key.lower().endswith((".jpg", ".jpeg", ".png")): continue # pula se já foi indexado cur.execute("SELECT id FROM face_embeddings WHERE key = %s", (key,)) if cur.fetchone(): continue image_bytes = s3.get_object(Bucket=BUCKET, Key=key)["Body"].read() embedding = compute_embedding(image_bytes) if embedding is None: continue # sem rosto detectado cur.execute( "INSERT INTO face_embeddings (key, embedding) VALUES (%s, %s)", (key, embedding.tolist()) ) indexed += 1 conn.commit() cur.close() conn.close() return {"indexed": indexed}
Passo 4: o endpoint de busca (/search)
Recebe a selfie do usuário, extrai o embedding e faz a busca por similaridade coseno no banco. O threshold de 0.5 funciona bem na prática, mas ajuste conforme seus testes.
from fastapi import UploadFile @app.post("/search") async def search(selfie: UploadFile, threshold: float = 0.5): image_bytes = await selfie.read() query_embedding = compute_embedding(image_bytes) if query_embedding is None: return {"error": "nenhum rosto detectado na selfie"} conn = psycopg2.connect("postgresql://user:pass@host/db") cur = conn.cursor() cur.execute(""" SELECT key, 1 - (embedding <=> %s::vector) as similarity FROM face_embeddings WHERE 1 - (embedding <=> %s::vector) > %s ORDER BY similarity DESC LIMIT 50 """, (query_embedding.tolist(), query_embedding.tolist(), threshold)) matches = [ {"key": row[0], "similarity": float(row[1])} for row in cur.fetchall() ] cur.close() conn.close() return {"matches": matches, "total": len(matches)}
A expressão 1 - distância_coseno vira similaridade coseno: quanto mais próximo de 1, mais parecidos os rostos.
O retorno tem essa forma:
{ "matches": [ { "key": "fotos/evento-0123.jpg", "similarity": 0.91 }, { "key": "fotos/evento-0456.jpg", "similarity": 0.78 } ], "total": 2 }
Com os keys retornados, o frontend pode gerar URLs assinadas do storage e exibir as fotos diretamente.
Passo 5: evitar cold-start no deploy
Se você for rodar em um ambiente serverless (Cloud Run, Lambda, Fly.io), o InsightFace baixa os modelos na primeira execução, o que pode demorar minutos e quebrar o cold-start. A solução é pré-baixar os modelos durante o build da imagem Docker:
FROM python:3.11-slim RUN pip install insightface onnxruntime opencv-python-headless # baixa o modelo no build, não no runtime RUN python -c "from insightface.app import FaceAnalysis; \ app = FaceAnalysis(name='buffalo_l', providers=['CPUExecutionProvider']); \ app.prepare(ctx_id=-1, det_size=(640, 640))" COPY . . CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
Isso aumenta o tamanho da imagem, mas o servidor sobe instantaneamente. Para processar embeddings em CPU, aloque pelo menos 2 vCPUs e 2GB de RAM.
Ajustando o threshold
O threshold de similaridade é o principal parâmetro para calibrar:
- Muito alto (ex: 0.8): poucos matches, pode perder fotos legítimas
- Muito baixo (ex: 0.3): muitos matches, aparece gente parecida mas que não é a mesma pessoa
- 0.45 a 0.55: boa faixa de partida para fotos de eventos com iluminação razoável
Teste com fotos reais do seu contexto. Rostos parcialmente encobertos, ângulos extremos ou iluminação muito ruim vão pedir um threshold mais tolerante.
O que você ganha com isso
pgvector resolve a busca vetorial sem precisar de infra adicional. Não é necessário Pinecone, Weaviate nem nenhum banco vetorial separado: o PostgreSQL que você provavelmente já tem na stack resolve bem para acervos de alguns milhares de fotos. A inferência em CPU é suficiente para esse volume, e a idempotência no /sync torna a indexação incremental e segura para rodar periodicamente.
O código completo pode ser adaptado para qualquer evento com álbum de fotos: conferências, casamentos, formaturas, competições esportivas.