Files
jhonny-editor/code/engine/testes/test_analise_visual_local.py
T
João Henrique c1b544f4b5 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
2026-09-10 08:58:22 -04:00

321 lines
15 KiB
Python

import unittest
from pathlib import Path
import json
import tempfile
from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
from engine.integracoes.visual.analisadores import AnalisadorDeRostosInsightFace
from engine.integracoes.visual.contratos import (AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros,
DetectorDeIntervalosDeCena,
ExtratorDeQuadros)
from engine.integracoes.visual.modelos import QuadroDeVideo
from engine.integracoes.visual.extrator_open_cv import ExtratorDeQuadrosOpenCV
from engine.integracoes.visual.apple_vision import (AnalisadorAppleVisionNativo,
AnalisadorSequenciaAppleVisionNativo)
from engine.scanner.coordenacao import ContextoDeAnalise
from engine.scanner.modelos import Cena, EvidenciaVisual
from engine.scanner.visual import (AnaliseVisualDaTimeline, DetectorDeCenas, ResultadoVisualDoClipe,
criar_detector_apple_vision, criar_detector_visual_local)
from engine.scanner.relatorio_visual import gerar_relatorio_visual, gerar_resumo_visual_markdown
from engine.scanner.configuracao_visual import ConfiguracaoVisualDoScanner
class RostoSimulado:
"""Dublê de um rosto devolvido por `FaceAnalysis.get`."""
def __init__(self, bbox, det_score=0.9, embedding=None, idade=None, genero=None):
self.bbox = bbox
self.det_score = det_score
self.normed_embedding = embedding
self.age = idade
self.sex = genero
class AppInsightFaceSimulado:
"""Dublê do `FaceAnalysis`: devolve uma lista fixa de rostos."""
def __init__(self, rostos):
self._rostos = rostos
def get(self, imagem):
self.imagem_recebida = imagem
return self._rostos
class ExtratorSimulado(ExtratorDeQuadros):
def extrair(self, clipe):
return [
QuadroDeVideo(0.0, 0, 1920, 1080, "frame-0"),
QuadroDeVideo(1.0, 1, 1920, 1080, "frame-1"),
]
class AnalisadorSimulado(AnalisadorDeFrame):
nome = "simulado"
def analisar(self, quadro):
return [EvidenciaVisual("qualidade", quadro.timestamp, quadro.timestamp,
{"nitidez_laplaciana": 42.0}, provider=self.nome)]
class DetectorSimulado(DetectorDeIntervalosDeCena):
nome = "simulado"
def detectar(self, clipe, quadros):
return [Cena(0.0, 1.0, referencias=(0.0,)), Cena(1.0, 2.0, referencias=(1.0,))]
class AnalisadorTemporalSimulado(AnalisadorDeSequenciaDeQuadros):
nome = "temporal_simulado"
def analisar(self, quadros):
return [EvidenciaVisual("continuidade", quadros[0].timestamp, quadros[-1].timestamp,
{"possivel_descontinuidade": False}, provider=self.nome)]
class ExecutorAppleSimulado:
def executar(self, carga, timeout):
self.carga, self.timeout = carga, timeout
return {"evidencias": [{"tipo": "pessoa", "valor": {"ocupacao_do_quadro": 0.3},
"confianca": 0.9, "modelo": "VNDetectHumanRectanglesRequest"}],
"avisos": ["ocr: indisponível"]}
class ExecutorAppleSequenciaSimulado:
def executar(self, carga, timeout):
self.carga, self.timeout = carga, timeout
return {"evidencias": [{"tipo": "similaridade_visual", "valor": {
"frame_inicial": 0, "frame_final": 1, "distancia_feature_print": 0.2,
"possivel_mudanca_de_cena": False,
}, "modelo": "VNGenerateImageFeaturePrintRequest"}], "avisos": []}
class TesteAnaliseVisualLocal(unittest.TestCase):
def setUp(self):
self.clipe = Clipe("clip-1", "camera", IntervaloDeTempo(0, 2), arquivo="/video.mp4")
def test_detector_envia_cada_frame_aos_analisadores_e_consolida_cenas(self):
detector = DetectorDeCenas(ExtratorSimulado(), [AnalisadorSimulado()], [DetectorSimulado()])
resultado = detector.detectar(self.clipe)
self.assertEqual(resultado.identificador_do_clipe, "clip-1")
self.assertEqual([item.inicio for item in resultado.evidencias], [0.0, 1.0])
self.assertEqual(len(resultado.cenas), 1)
self.assertEqual((resultado.cenas[0].inicio, resultado.cenas[0].fim), (0.0, 2.0))
def test_etapa_da_timeline_guarda_evidencias_por_clipe(self):
detector = DetectorDeCenas(ExtratorSimulado(), [AnalisadorSimulado()], [DetectorSimulado()])
timeline = Timeline("timeline-1", "Principal", faixas=[
Faixa("video-1", "V1", "video", 0, [self.clipe]),
])
contexto = ContextoDeAnalise(timeline=timeline)
AnaliseVisualDaTimeline(detector).executar(contexto)
self.assertEqual(len(contexto.caracteristicas_visuais["clip-1"]["evidencias"]), 2)
self.assertEqual(len(contexto.cenas), 1)
def test_detector_executa_analisadores_de_sequencia_uma_vez_por_clipe(self):
detector = DetectorDeCenas(ExtratorSimulado(), analisadores_de_sequencia=[AnalisadorTemporalSimulado()])
resultado = detector.detectar(self.clipe)
self.assertEqual(len(resultado.evidencias), 1)
self.assertEqual(resultado.evidencias[0].tipo, "continuidade")
def test_fluxo_produz_cena_visual_com_observacoes_e_continuidade(self):
detector = DetectorDeCenas(
ExtratorSimulado(), [AnalisadorSimulado()],
analisadores_de_sequencia=[AnalisadorTemporalSimulado()],
)
contexto = ContextoDeAnalise(timeline=Timeline(
"timeline-1", "Principal", faixas=[Faixa("video-1", "V1", "video", 0, [self.clipe])]
))
AnaliseVisualDaTimeline(detector).executar(contexto)
cena = contexto.cenas_visuais[0]
self.assertEqual(cena.identificador_do_clipe, "clip-1")
self.assertEqual([item.tipo for item in cena.observacoes], ["qualidade", "qualidade", "continuidade"])
self.assertTrue(contexto.caracteristicas_visuais["clip-1"]["disponivel"])
def test_falha_de_adapter_e_registrada_sem_interromper_outro_clipe(self):
class ExtratorQueFalha(ExtratorDeQuadros):
def extrair(self, clipe):
if clipe.identificador == "clip-1":
raise RuntimeError("OpenCV não instalado")
return [QuadroDeVideo(0.0, 0, 1920, 1080, "frame")]
segundo = Clipe("clip-2", "camera-2", IntervaloDeTempo(2, 3), arquivo="/video-2.mp4")
timeline = Timeline("timeline-1", "Principal", faixas=[
Faixa("video-1", "V1", "video", 0, [self.clipe, segundo]),
])
contexto = ContextoDeAnalise(timeline=timeline)
detector = DetectorDeCenas(ExtratorQueFalha(), [AnalisadorSimulado()])
AnaliseVisualDaTimeline(detector).executar(contexto)
self.assertFalse(contexto.caracteristicas_visuais["clip-1"]["disponivel"])
self.assertTrue(contexto.caracteristicas_visuais["clip-2"]["disponivel"])
self.assertEqual(len(contexto.avisos), 1)
def test_adapter_apple_converte_fatos_e_preserva_avisos_por_recurso(self):
executor = ExecutorAppleSimulado()
quadro = QuadroDeVideo(0.0, 0, 1920, 1080, "frame", Path("/frame.jpg"))
evidencias = AnalisadorAppleVisionNativo(executor=executor).analisar(quadro)
self.assertEqual(evidencias[0].tipo, "pessoa")
self.assertEqual(evidencias[0].modelo, "VNDetectHumanRectanglesRequest")
self.assertEqual(evidencias[1].tipo, "diagnostico_apple_vision")
self.assertTrue(executor.carga["resumir"])
def test_adapter_apple_de_sequencia_converte_indices_em_intervalos(self):
executor = ExecutorAppleSequenciaSimulado()
quadros = [QuadroDeVideo(2.0, 0, 1920, 1080, "frame", Path("/frame-0.jpg")),
QuadroDeVideo(3.0, 1, 1920, 1080, "frame", Path("/frame-1.jpg"))]
evidencias = AnalisadorSequenciaAppleVisionNativo(executor=executor).analisar(quadros)
self.assertEqual(evidencias[0].tipo, "similaridade_visual")
self.assertEqual((evidencias[0].inicio, evidencias[0].fim), (2.0, 3.0))
self.assertEqual(evidencias[0].valor["distancia_feature_print"], 0.2)
self.assertEqual(executor.carga["frames"], ["/frame-0.jpg", "/frame-1.jpg"])
def test_evidencia_rejeita_intervalo_invalido(self):
with self.assertRaises(ValueError):
EvidenciaVisual("objeto", 2.0, 1.0, {})
def test_relatorio_visual_separa_frames_de_evidencias_temporais(self):
resultado = ResultadoVisualDoClipe("clip-1", [
EvidenciaVisual("categoria", 2.0, 2.0, {"identificador": "drink"}),
EvidenciaVisual("similaridade_visual", 2.0, 3.0, {"distancia": 0.2}),
])
with tempfile.TemporaryDirectory() as pasta:
caminho = gerar_relatorio_visual(self.clipe, resultado, Path(pasta) / "visual.json", 1.0)
dados = json.loads(caminho.read_text(encoding="utf-8"))
self.assertEqual(dados["clipe"]["identificador"], "clip-1")
self.assertEqual(dados["observacoes_por_frame"][0]["timestamp_na_origem"], 2.0)
self.assertEqual(dados["observacoes_por_frame"][0]["evidencias"][0]["tipo"], "categoria")
self.assertEqual(dados["evidencias_temporais"][0]["tipo"], "similaridade_visual")
def test_resumo_visual_markdown_lista_frames_e_transicoes(self):
resultado = ResultadoVisualDoClipe("clip-1", [
EvidenciaVisual("categoria", 2.0, 2.0, {"identificador": "drink"}),
EvidenciaVisual("similaridade_visual", 2.0, 3.0, {"distancia": 0.2}),
])
with tempfile.TemporaryDirectory() as pasta:
caminho = gerar_resumo_visual_markdown(self.clipe, resultado, Path(pasta) / "visual.md", 1.0)
conteudo = caminho.read_text(encoding="utf-8")
self.assertIn("Origem 2.000s", conteudo)
self.assertIn("similaridade_visual", conteudo)
def test_configuracao_visual_valida_o_perfil_do_painel(self):
perfil = ConfiguracaoVisualDoScanner.de_dict({
"intervalo_em_segundos": 5,
"faixas_de_video": [2, 0, 2],
"recursos_vision": ["faces", "pessoas"],
"analisar_continuidade": False,
})
self.assertEqual(perfil.faixas_de_video, (0, 2))
self.assertEqual(perfil.para_dict()["recursos_vision"], ["faces", "pessoas"])
self.assertFalse(perfil.analisar_continuidade)
def test_extrator_respeita_intervalo_na_origem_e_limite_de_frames(self):
clipe = Clipe("clip-1", "camera", IntervaloDeTempo(0, 2),
intervalo_na_origem=IntervaloDeTempo(2.3, 5.0), arquivo="/video.mp4")
extrator = ExtratorDeQuadrosOpenCV(intervalo_em_segundos=1.0, maximo_de_quadros=1,
cv2_module=object())
inicio, fim = extrator._intervalo_de_origem(clipe, 10.0)
self.assertEqual(extrator._timestamps(inicio, fim), [2.3])
def test_insightface_converte_rosto_em_evidencias_de_rosto_e_identidade(self):
rosto = RostoSimulado(bbox=[100.0, 50.0, 300.0, 350.0], det_score=0.87,
embedding=[0.1, 0.2, 0.3], idade=28, genero="F")
analisador = AnalisadorDeRostosInsightFace(app=AppInsightFaceSimulado([rosto]))
quadro = QuadroDeVideo(1.5, 0, 1000, 1000, imagem="frame-bgr")
evidencias = analisador.analisar(quadro)
self.assertEqual([item.tipo for item in evidencias], ["rosto", "identidade_facial"])
caixa = evidencias[0].valor["bounding_box"]
self.assertAlmostEqual(caixa["x"], 0.1)
self.assertAlmostEqual(caixa["y"], 0.05)
self.assertAlmostEqual(caixa["largura"], 0.2)
self.assertAlmostEqual(caixa["altura"], 0.3)
self.assertEqual(evidencias[0].confianca, 0.87)
identidade = evidencias[1].valor
self.assertEqual(identidade["assinatura_facial"], [0.1, 0.2, 0.3])
self.assertEqual(identidade["dimensoes"], 3)
self.assertEqual(identidade["idade_aproximada"], 28)
self.assertEqual(identidade["genero_aparente"], "F")
def test_insightface_descarta_rosto_menor_que_o_minimo_relativo(self):
rosto_pequeno = RostoSimulado(bbox=[0.0, 0.0, 10.0, 10.0])
analisador = AnalisadorDeRostosInsightFace(app=AppInsightFaceSimulado([rosto_pequeno]))
quadro = QuadroDeVideo(0.0, 0, 1000, 1000, imagem="frame-bgr")
self.assertEqual(analisador.analisar(quadro), [])
def test_insightface_nao_gera_identidade_sem_embedding(self):
rosto_sem_embedding = RostoSimulado(bbox=[100.0, 50.0, 300.0, 350.0])
analisador = AnalisadorDeRostosInsightFace(app=AppInsightFaceSimulado([rosto_sem_embedding]))
quadro = QuadroDeVideo(0.0, 0, 1000, 1000, imagem="frame-bgr")
evidencias = analisador.analisar(quadro)
self.assertEqual([item.tipo for item in evidencias], ["rosto"])
def test_insightface_exige_imagem_decodificada_ou_caminho_persistido(self):
analisador = AnalisadorDeRostosInsightFace(app=AppInsightFaceSimulado([]))
quadro = QuadroDeVideo(0.0, 0, 1000, 1000, imagem=None, caminho=None)
with self.assertRaises(ValueError):
analisador.analisar(quadro)
def test_insightface_rejeita_tamanho_minimo_relativo_invalido(self):
with self.assertRaises(ValueError):
AnalisadorDeRostosInsightFace(tamanho_minimo_relativo=1.5)
def test_detector_local_inclui_rostos_opencv_quando_recurso_faces_ativo(self):
detector = criar_detector_visual_local()
nomes = [type(item).__name__ for item in detector.analisadores_de_frame]
self.assertIn("AnalisadorDeRostosOpenCV", nomes)
self.assertNotIn("AnalisadorDeRostosInsightFace", nomes)
def test_detector_local_inclui_insightface_quando_identidade_facial_ativa(self):
perfil = ConfiguracaoVisualDoScanner(identidade_facial=True)
detector = criar_detector_visual_local(configuracao=perfil)
nomes = [type(item).__name__ for item in detector.analisadores_de_frame]
self.assertIn("AnalisadorDeRostosInsightFace", nomes)
def test_detector_apple_vision_inclui_insightface_quando_identidade_facial_ativa(self):
perfil = ConfiguracaoVisualDoScanner(identidade_facial=True)
detector = criar_detector_apple_vision(configuracao=perfil)
nomes = [type(item).__name__ for item in detector.analisadores_de_frame]
self.assertIn("AnalisadorAppleVisionNativo", nomes)
self.assertIn("AnalisadorDeRostosInsightFace", nomes)
def test_detector_apple_vision_nao_inclui_insightface_por_padrao(self):
detector = criar_detector_apple_vision()
nomes = [type(item).__name__ for item in detector.analisadores_de_frame]
self.assertNotIn("AnalisadorDeRostosInsightFace", nomes)
if __name__ == "__main__":
unittest.main()