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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user