- 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
321 lines
15 KiB
Python
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()
|