feat: reorganizado o fluxo de edição para validar a análise do Sca

- reorganizado o fluxo de edição para validar a análise do Scanner, selecionar o tipo de vídeo e enviar suas instruções no JSON.
- banco de análises: métricas de fala viraram colunas, busca lexical FTS5, enunciados com embeddings, views achatadas de leitura (fala/palavra/linha do tempo/fala com visual), ingestor do pipeline de voz e gravação idempotente de evidências visuais e cenas.
- retakes passam a ser gravados no banco de análises (SQLite) em vez de JSON por caso, com status de revisão persistido.
- planos de edição e suas aplicações passam a ser registrados no banco, em vez de se perderem no arquivo temporário.
- comando de limpeza das evidências visuais e cenas duplicadas por execuções antigas do Scanner.
- análise visual: OpenCV (rostos) e PySceneDetect passam a entrar no detector local por padrão; novo adapter InsightFace gera assinatura facial (embedding) para reconhecer a mesma pessoa entre tomadas, gravada como evidência visual no banco.
- corrigido: métricas de fala (energia, pitch, velocidade) agora gravam nas colunas dedicadas, não só no JSON; a view fala+visual passa a casar por vídeo e tempo, já que o mesmo arquivo entra na timeline como clipes distintos de vídeo e áudio; views são recriadas a cada abertura do banco para uma correção de consulta chegar a bancos já existentes.
- reordenadas as abas do painel CEP para Scanner, Refinar e Editar vídeo

Resumo:
- 24 arquivos alterados
- 9 novos
- 15 modificados
- 0 removidos

 15 files changed, 872 insertions(+), 29 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/cep-plugin/index.html
  - code/cep-plugin/main.js
  - code/engine/aplicar_plano_de_edicao.py
  - code/engine/integracoes/visual/README.md
  - code/engine/integracoes/visual/__init__.py
  - code/engine/integracoes/visual/analisadores.py
  - code/engine/persistencia/__init__.py
  - code/engine/persistencia/esquema.py
  - code/engine/persistencia/repositorio_de_analises_sqlite.py
  - code/engine/persistencia/repositorio_de_retakes_sqlite.py
  - code/engine/requirements-visual.txt
  - code/engine/scanner/configuracao_visual.py
  - code/engine/scanner/visual.py
  - code/engine/testes/test_analise_visual_local.py
  - .jhonny/analises.db.pos-scanner-084841
  - code/engine/integracoes/embeddings/
  - code/engine/limpar_duplicatas.py
  - code/engine/persistencia/busca_de_conteudo.py
  - code/engine/persistencia/enunciados.py
  - code/engine/persistencia/ingestao_de_voz.py
  - code/engine/persistencia/limpeza.py
  - code/engine/persistencia/repositorio_de_planos.py
  - code/engine/testes/test_busca_de_conteudo.py
This commit is contained in:
João Henrique
2026-09-10 08:58:22 -04:00
parent 1156619937
commit c1b544f4b5
25 changed files with 2792 additions and 29 deletions
@@ -0,0 +1,21 @@
"""Providers de embeddings usados pela busca semântica de conteúdo."""
from .provider_ollama import (
DIMENSOES_PADRAO,
ErroDeEmbedding,
MODELO_PADRAO,
ProviderDeEmbeddings,
ProviderDeEmbeddingsOllama,
desempacotar,
empacotar,
)
__all__ = [
"DIMENSOES_PADRAO",
"ErroDeEmbedding",
"MODELO_PADRAO",
"ProviderDeEmbeddings",
"ProviderDeEmbeddingsOllama",
"desempacotar",
"empacotar",
]
@@ -0,0 +1,180 @@
"""
Geração de embeddings por um Ollama local.
O projeto já roda um Ollama nesta máquina com o ``nomic-embed-text`` — o mesmo
modelo que o RAG de código usa. Reaproveitá-lo mantém a análise de conteúdo
local: nenhum trecho de transcrição de cliente sai da máquina, e não há custo
por chamada.
O ``nomic-embed-text`` exige prefixos distintos para o que é indexado e para o
que é consultado. Sem eles a similaridade cai de forma silenciosa, então os
prefixos são aplicados aqui e não ficam a cargo de quem chama.
"""
from __future__ import annotations
import json
import struct
import urllib.error
import urllib.request
from typing import Protocol, Sequence
URL_PADRAO = "http://localhost:11434"
MODELO_PADRAO = "nomic-embed-text"
DIMENSOES_PADRAO = 768
PREFIXO_DE_DOCUMENTO = "search_document: "
PREFIXO_DE_CONSULTA = "search_query: "
class ErroDeEmbedding(RuntimeError):
"""Falha ao gerar um embedding — serviço fora do ar, modelo ausente ou resposta inválida."""
class ProviderDeEmbeddings(Protocol):
"""Contrato de quem transforma texto em vetor."""
@property
def modelo(self) -> str:
"""Nome do modelo que gerou os vetores."""
...
@property
def dimensoes(self) -> int:
"""Quantidade de dimensões dos vetores gerados."""
...
def gerar_para_documento(self, texto: str) -> tuple[float, ...]:
"""Gera o vetor de um texto que será indexado."""
...
def gerar_para_consulta(self, texto: str) -> tuple[float, ...]:
"""Gera o vetor de um texto que está sendo buscado."""
...
class ProviderDeEmbeddingsOllama:
"""
Gera embeddings chamando um Ollama local por HTTP.
Atributos:
modelo: Nome do modelo de embeddings usado.
dimensoes: Dimensões esperadas nos vetores devolvidos.
url: Endereço base do serviço Ollama.
tempo_limite: Segundos a esperar por cada resposta.
"""
def __init__(
self,
modelo: str = MODELO_PADRAO,
dimensoes: int = DIMENSOES_PADRAO,
url: str = URL_PADRAO,
tempo_limite: float = 60.0,
) -> None:
"""
Inicializa o provider com o modelo e o endereço do serviço.
Parâmetros:
modelo: Nome do modelo de embeddings no Ollama.
dimensoes: Dimensões esperadas — vetores de outro tamanho são
recusados, para não misturar modelos no mesmo índice.
url: Endereço base do Ollama.
tempo_limite: Segundos a esperar por cada resposta.
"""
self._modelo = modelo
self._dimensoes = dimensoes
self.url = url.rstrip("/")
self.tempo_limite = tempo_limite
@property
def modelo(self) -> str:
"""Nome do modelo que gera os vetores."""
return self._modelo
@property
def dimensoes(self) -> int:
"""Quantidade de dimensões dos vetores gerados."""
return self._dimensoes
def gerar_para_documento(self, texto: str) -> tuple[float, ...]:
"""
Gera o vetor de um enunciado que será indexado.
Parâmetros:
texto: Texto do enunciado.
Retorna:
O vetor normalizado do texto.
Pode gerar:
ErroDeEmbedding: quando o Ollama não responde ou devolve um vetor
com dimensões diferentes das esperadas.
"""
return self._gerar(PREFIXO_DE_DOCUMENTO + texto)
def gerar_para_consulta(self, texto: str) -> tuple[float, ...]:
"""
Gera o vetor de uma consulta feita pelo usuário.
Parâmetros:
texto: Texto da consulta.
Retorna:
O vetor normalizado da consulta.
Pode gerar:
ErroDeEmbedding: quando o Ollama não responde ou devolve um vetor
com dimensões diferentes das esperadas.
"""
return self._gerar(PREFIXO_DE_CONSULTA + texto)
def _gerar(self, texto: str) -> tuple[float, ...]:
"""Chama o Ollama e valida o vetor devolvido."""
corpo = json.dumps({"model": self._modelo, "prompt": texto}).encode("utf-8")
requisicao = urllib.request.Request(
f"{self.url}/api/embeddings", data=corpo,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(requisicao, timeout=self.tempo_limite) as resposta:
dados = json.loads(resposta.read().decode("utf-8"))
except urllib.error.URLError as erro:
raise ErroDeEmbedding(
f"Não foi possível falar com o Ollama em {self.url}: {erro}"
) from erro
except json.JSONDecodeError as erro:
raise ErroDeEmbedding(f"Resposta do Ollama não é JSON válido: {erro}") from erro
vetor = dados.get("embedding")
if not isinstance(vetor, list) or not vetor:
raise ErroDeEmbedding(f"O Ollama não devolveu embedding para o modelo {self._modelo}.")
if len(vetor) != self._dimensoes:
raise ErroDeEmbedding(
f"Embedding com {len(vetor)} dimensões, esperado {self._dimensoes}."
)
return tuple(float(valor) for valor in vetor)
def empacotar(vetor: Sequence[float]) -> bytes:
"""
Serializa um vetor para o BLOB gravado no banco.
Parâmetros:
vetor: Valores do vetor.
Retorna:
Os valores como float de 32 bits em sequência.
"""
return struct.pack(f"<{len(vetor)}f", *vetor)
def desempacotar(dados: bytes) -> tuple[float, ...]:
"""
Reconstrói um vetor a partir do BLOB lido do banco.
Parâmetros:
dados: Bytes gravados por ``empacotar``.
Retorna:
Os valores do vetor.
"""
return struct.unpack(f"<{len(dados) // 4}f", dados)
+16 -1
View File
@@ -62,6 +62,7 @@ testadas isoladamente.
| Continuidade e frame congelado | `AnalisadorDeContinuidadeOpenCV` |
| Detectar objetos | `AnalisadorDeObjetosONNX` |
| Detectar pose e mãos | `AnalisadorDePoseMediaPipe` |
| Reconhecer a mesma pessoa entre tomadas (assinatura facial) | `AnalisadorDeRostosInsightFace` |
| OCR e faces macOS | `AnalisadorAppleVision` |
| Vision + Apple Intelligence (Swift isolado) | `AnalisadorAppleVisionNativo` |
| Similaridade temporal por feature print | `AnalisadorSequenciaAppleVisionNativo` |
@@ -82,7 +83,21 @@ encerrar o processo do Scanner.
`AnalisadorDeRostosOpenCV` usa os arquivos locais
`haarcascade_frontalface_default.xml` e `haarcascade_eye.xml`. Quando a
distribuição do OpenCV não os incluir, forneça `diretorio_de_modelos` apontando
para a pasta que os contém.
para a pasta que os contém. Ele entra automaticamente no detector local
(`criar_detector_visual_local`) quando `"faces"` está em `recursos_vision`
(padrão do perfil).
`AnalisadorDeRostosInsightFace` resolve o que nem o OpenCV nem o Apple Vision
fazem: reconhecer que o rosto de um clipe é a mesma pessoa de outro clipe. Ele
devolve duas evidências por rosto — `rosto` (mesma forma de bounding box dos
outros adapters) e `identidade_facial` (com `assinatura_facial`, um vetor de
512 posições, mais `idade_aproximada`/`genero_aparente` quando o modelo os
estimar). Aceita tanto o frame decodificado em `quadro.imagem` (pipeline
OpenCV) quanto um frame persistido em `quadro.caminho` (pipeline Apple
Vision/FFmpeg), lendo o PNG com OpenCV nesse segundo caso. Por ser mais pesado
(baixa o modelo `buffalo_l` na primeira execução), só entra nos detectores
locais e no detector Apple Vision quando `perfil.identidade_facial=True` —
desligado por padrão.
`AnalisadorAppleVisionNativo` é a opção Apple recomendada. Seu runner Swift
executa faces/landmarks, qualidade facial, pessoas, pose, mãos, OCR,
+3 -2
View File
@@ -1,7 +1,8 @@
from .analisadores import (AnalisadorAppleVision, AnalisadorDeComposicaoOpenCV,
AnalisadorDeContinuidadeOpenCV, AnalisadorDeObjetosONNX,
AnalisadorDePoseMediaPipe, AnalisadorDeQualidadeOpenCV,
AnalisadorDeRostosOpenCV, AnalisadorDeTremorOpenCV)
AnalisadorDeRostosInsightFace, AnalisadorDeRostosOpenCV,
AnalisadorDeTremorOpenCV)
from .apple_vision import (AnalisadorAppleVisionNativo, AnalisadorSequenciaAppleVisionNativo,
ExecutorDoRunnerApple)
from .contratos import (AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros,
@@ -14,7 +15,7 @@ from .modelos import QuadroDeVideo
__all__ = ["AnalisadorAppleVision", "AnalisadorAppleVisionNativo", "AnalisadorSequenciaAppleVisionNativo", "AnalisadorDeComposicaoOpenCV",
"AnalisadorDeContinuidadeOpenCV", "AnalisadorDeFrame",
"AnalisadorDeObjetosONNX", "AnalisadorDePoseMediaPipe",
"AnalisadorDeQualidadeOpenCV", "AnalisadorDeRostosOpenCV",
"AnalisadorDeQualidadeOpenCV", "AnalisadorDeRostosInsightFace", "AnalisadorDeRostosOpenCV",
"AnalisadorDeSequenciaDeQuadros", "AnalisadorDeTremorOpenCV",
"DetectorDeCenasPySceneDetect", "DetectorDeIntervalosDeCena",
"ExecutorDoRunnerApple", "ExtratorDeQuadros", "ExtratorDeQuadrosFFmpeg", "ExtratorDeQuadrosOpenCV", "QuadroDeVideo"]
@@ -105,6 +105,113 @@ class AnalisadorDeRostosOpenCV(_AdapterOpenCV, AnalisadorDeFrame):
return self._detectores
class AnalisadorDeRostosInsightFace(AnalisadorDeFrame):
"""Detecta rostos e extrai a assinatura facial (embedding) com InsightFace.
Complementa `AnalisadorDeRostosOpenCV` e o Apple Vision: ambos encontram
o rosto, mas nenhum reconhece que dois rostos em clipes diferentes são a
mesma pessoa. A assinatura facial normalizada é o fato que a camada
editorial usa depois para agrupar tomadas pela pessoa em cena.
"""
nome = "insightface"
def __init__(self, modelo: str = "buffalo_l", tamanho_de_deteccao: tuple[int, int] = (640, 640),
usar_coreml: bool = True, tamanho_minimo_relativo: float = 0.04,
app: Any | None = None) -> None:
if tamanho_minimo_relativo < 0 or tamanho_minimo_relativo >= 1:
raise ValueError("tamanho_minimo_relativo deve estar entre 0 e 1.")
self.modelo = modelo
self.tamanho_de_deteccao = tamanho_de_deteccao
self.usar_coreml = usar_coreml
self.tamanho_minimo_relativo = tamanho_minimo_relativo
self._app = app
@property
def app(self) -> Any:
"""Instância preparada do `FaceAnalysis`, criada de forma tardia."""
if self._app is None:
try:
from insightface.app import FaceAnalysis
except ImportError as exc:
raise RuntimeError(
"InsightFace não está instalado. Instale insightface e onnxruntime."
) from exc
self._app = self._preparar_app(FaceAnalysis)
return self._app
def _preparar_app(self, classe_face_analysis: Any) -> Any:
"""Monta o `FaceAnalysis` com os providers do ONNX Runtime disponíveis."""
try:
import onnxruntime
except ImportError as exc:
raise RuntimeError("InsightFace requer onnxruntime instalado.") from exc
preferidos = ["CoreMLExecutionProvider", "CPUExecutionProvider"] if self.usar_coreml \
else ["CPUExecutionProvider"]
disponiveis = set(onnxruntime.get_available_providers())
providers = [item for item in preferidos if item in disponiveis] or ["CPUExecutionProvider"]
app = classe_face_analysis(name=self.modelo, providers=providers)
app.prepare(ctx_id=0, det_size=self.tamanho_de_deteccao)
return app
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
"""Devolve uma evidência de rosto e, quando possível, sua assinatura facial."""
rostos = self.app.get(self._obter_imagem(quadro))
evidencias: list[EvidenciaVisual] = []
for rosto in rostos:
caixa = _caixa_normalizada_do_retangulo(rosto.bbox, quadro.largura, quadro.altura)
if min(caixa["largura"], caixa["altura"]) < self.tamanho_minimo_relativo:
continue
confianca = float(rosto.det_score) if getattr(rosto, "det_score", None) is not None else None
evidencias.append(EvidenciaVisual("rosto", quadro.timestamp, quadro.timestamp,
{"bounding_box": caixa, "proximo_da_borda": _proximo_da_borda(caixa)},
confianca=confianca, provider=self.nome, modelo=self.modelo))
evidencia_de_identidade = self._evidencia_de_identidade(rosto, quadro, caixa, confianca)
if evidencia_de_identidade is not None:
evidencias.append(evidencia_de_identidade)
return evidencias
def _obter_imagem(self, quadro: QuadroDeVideo) -> Any:
"""Devolve a matriz BGR do frame, decodificando o arquivo quando necessário.
O extrator local (OpenCV) já entrega o frame decodificado em
``quadro.imagem``. O extrator usado com o Apple Vision persiste PNGs
e mantém ``imagem`` vazio para não carregar OpenCV nesse processo; o
InsightFace precisa do array decodificado, então lê o PNG nesse caso.
"""
if quadro.imagem is not None:
return quadro.imagem
if quadro.caminho is None:
raise ValueError("InsightFace requer o frame decodificado ou persistido em disco.")
try:
import cv2
except ImportError as exc:
raise RuntimeError("Ler o frame do disco para o InsightFace requer OpenCV.") from exc
imagem = cv2.imread(str(quadro.caminho))
if imagem is None:
raise RuntimeError(f"Não foi possível ler o frame do InsightFace: {quadro.caminho}")
return imagem
def _evidencia_de_identidade(self, rosto: Any, quadro: QuadroDeVideo, caixa: dict[str, float],
confianca: float | None) -> EvidenciaVisual | None:
embedding = getattr(rosto, "normed_embedding", None)
if embedding is None:
return None
valor: dict[str, Any] = {
"bounding_box": caixa,
"assinatura_facial": [float(item) for item in embedding],
"dimensoes": len(embedding),
}
idade = getattr(rosto, "age", None)
if idade is not None:
valor["idade_aproximada"] = int(idade)
genero = getattr(rosto, "sex", None)
if genero is not None:
valor["genero_aparente"] = str(genero)
return EvidenciaVisual("identidade_facial", quadro.timestamp, quadro.timestamp, valor,
confianca=confianca, provider=self.nome, modelo=self.modelo)
class AnalisadorDeComposicaoOpenCV(_AdapterOpenCV, AnalisadorDeFrame):
"""Mede orientação, poluição visual e disponibilidade das zonas para legendas."""
@@ -302,6 +409,20 @@ def _caixa_normalizada(x: int, y: int, largura: int, altura: int,
"largura": float(largura / largura_do_frame), "altura": float(altura / altura_do_frame)}
def _caixa_normalizada_do_retangulo(bbox: Any, largura_do_frame: int, altura_do_frame: int) -> dict[str, float]:
"""Converte um retângulo em cantos ``[x1, y1, x2, y2]`` (pixels) para a caixa normalizada.
Detectores como o InsightFace podem devolver cantos levemente fora do
quadro; por isso o resultado é sempre recortado para o intervalo [0, 1].
"""
x1, y1, x2, y2 = (float(valor) for valor in bbox)
x = min(max(x1 / largura_do_frame, 0.0), 1.0)
y = min(max(y1 / altura_do_frame, 0.0), 1.0)
largura = min(max(x2 / largura_do_frame, 0.0), 1.0) - x
altura = min(max(y2 / altura_do_frame, 0.0), 1.0) - y
return {"x": x, "y": y, "largura": max(largura, 0.0), "altura": max(altura, 0.0)}
def _proximo_da_borda(caixa: dict[str, float], margem: float = 0.04) -> bool:
return (caixa["x"] < margem or caixa["y"] < margem
or caixa["x"] + caixa["largura"] > 1 - margem