feat: criado repositório jhonny-editor no Gitea
criado repositório jhonny-editor no Gitea adicionado script admin/deploy.command com commit automático atualizado admin/DEV-NOTES.md com template limpo Resumo: - 48 arquivos alterados - 26 novos - 19 modificados - 3 removidos 22 files changed, 658 insertions(+), 568 deletions(-) Arquivos: - AGENTS.md - admin/DEV-NOTES.md - admin/OpenCut.command - admin/commit.command - admin/deploy.command - admin/update.command - code/cep-plugin/index.html - code/cep-plugin/main.js - code/cep-plugin/styles.css - code/engine/ARQUITETURA.md - code/engine/arquitetura/README.md - code/engine/gerar_relatorio_timeline.py - code/engine/integracoes/apple_speech/apple_speech_transcriber.swift - code/engine/integracoes/apple_speech/provider_de_transcricao_apple.py - code/engine/integracoes/midia/__init__.py - code/engine/integracoes/whisper/provider_de_transcricao_local.py - code/engine/scanner/__init__.py - code/engine/scanner/coordenacao/__init__.py - code/engine/scanner/descoberta/__init__.py - code/engine/scanner/modelos.py - code/engine/scanner/transcricao_da_timeline.py - code/src/tools/discovery.ts - :memory:.ses - admin/Jhonny.command - admin/inativos/OpenCut.command - admin/inativos/update.command - code/docs/glossario-analise-emocional.md - code/docs/levantamento-apple-vision-e-apple-intelligence.md - code/docs/levantamento-ferramentas-analise-visual-local.md - code/engine/arquitetura/biblioteca-inteligente-de-videos.md - code/engine/executar_scanner.py - code/engine/integracoes/huggingface/ - code/engine/integracoes/midia/extracao_de_metadados.py - code/engine/integracoes/visual/ - code/engine/requirements-visual.txt - code/engine/scanner/configuracao_visual.py - code/engine/scanner/descoberta/descoberta_de_arquivos.py - code/engine/scanner/metadados.py - code/engine/scanner/relatorio_visual.py - code/engine/scanner/retakes/ - code/engine/scanner/visual.py - code/engine/testes/test_analise_visual_local.py - code/engine/testes/test_arquivos_e_metadados.py - code/engine/testes/test_provider_de_transcricao_apple.py - code/relatorios/analise-brools/ - code/relatorios/analise-visual/ - code/relatorios/audio/arquivos/ - code/relatorios/transcricao-timeline.md
This commit is contained in:
@@ -1,7 +1,22 @@
|
||||
from .coordenacao import AnalisadorDeTimeline, ContextoDeAnalise, PipelineDoScanner
|
||||
|
||||
__all__ = ["AnalisadorDeTimeline", "ContextoDeAnalise", "PipelineDoScanner"]
|
||||
from .modelos import Cena, ErroDeAnalise, Evento, ResultadoDaAnalise, SegmentoDeTranscricao, StatusDaAnalise
|
||||
from .descoberta import ArquivoDescoberto, DescobertaDeArquivos, LeitorLocalDeArquivos
|
||||
from .metadados import ExtracaoDeMetadados, MetadadosDoArquivo
|
||||
from .modelos import (Cena, CenaVisual, EvidenciaVisual, ErroDeAnalise, Evento, ObservacaoVisual,
|
||||
PalavraDeTranscricao,
|
||||
ResultadoDaAnalise, SegmentoDeTranscricao, StatusDaAnalise)
|
||||
from .transcricao_da_timeline import TranscricaoDaTimeline, TranscricaoDoClipe
|
||||
from .visual import (AnaliseVisualDaTimeline, DetectorDeCenas, ResultadoVisualDoClipe,
|
||||
criar_detector_apple_vision, criar_detector_visual_local)
|
||||
from .relatorio_visual import gerar_relatorio_visual, gerar_resumo_visual_markdown
|
||||
from .configuracao_visual import ConfiguracaoVisualDoScanner
|
||||
|
||||
__all__ = ["Cena", "ErroDeAnalise", "Evento", "ResultadoDaAnalise", "SegmentoDeTranscricao", "StatusDaAnalise", "TranscricaoDaTimeline", "TranscricaoDoClipe"]
|
||||
__all__ = ["AnaliseVisualDaTimeline", "AnalisadorDeTimeline", "ArquivoDescoberto",
|
||||
"Cena", "CenaVisual", "ContextoDeAnalise", "criar_detector_apple_vision", "criar_detector_visual_local", "DescobertaDeArquivos", "DetectorDeCenas",
|
||||
"ConfiguracaoVisualDoScanner",
|
||||
"EvidenciaVisual", "ErroDeAnalise", "Evento", "ExtracaoDeMetadados",
|
||||
"gerar_relatorio_visual",
|
||||
"gerar_resumo_visual_markdown",
|
||||
"LeitorLocalDeArquivos", "MetadadosDoArquivo", "ObservacaoVisual", "PalavraDeTranscricao",
|
||||
"PipelineDoScanner", "ResultadoDaAnalise", "ResultadoVisualDoClipe",
|
||||
"SegmentoDeTranscricao", "StatusDaAnalise", "TranscricaoDaTimeline",
|
||||
"TranscricaoDoClipe"]
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Perfil declarativo que liga a configuração do painel à leitura visual."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
RECURSOS_VISION = frozenset((
|
||||
"faces", "qualidade_facial", "pessoas", "pose", "maos", "ocr", "categorias",
|
||||
"estetica", "codigos", "horizonte", "retangulos", "contornos", "segmentacao_de_pessoas",
|
||||
))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfiguracaoVisualDoScanner:
|
||||
"""Interface curta para uma execução de leitura visual da timeline."""
|
||||
|
||||
intervalo_em_segundos: float = 1.0
|
||||
faixas_de_video: tuple[int, ...] = ()
|
||||
faixas_de_audio: tuple[int, ...] = ()
|
||||
recursos_vision: frozenset[str] = RECURSOS_VISION
|
||||
detectar_cenas: bool = True
|
||||
analisar_continuidade: bool = True
|
||||
preparar_reenquadramento: bool = False
|
||||
interpretar_cena: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not 0.04 <= self.intervalo_em_segundos <= 60:
|
||||
raise ValueError("intervalo_em_segundos deve estar entre 0,04 e 60.")
|
||||
desconhecidos = self.recursos_vision - RECURSOS_VISION
|
||||
if desconhecidos:
|
||||
raise ValueError(f"Recursos Vision desconhecidos: {', '.join(sorted(desconhecidos))}")
|
||||
if any(indice < 0 for indice in self.faixas_de_video + self.faixas_de_audio):
|
||||
raise ValueError("Índices de faixa não podem ser negativos.")
|
||||
|
||||
@classmethod
|
||||
def de_dict(cls, dados: dict[str, Any]) -> "ConfiguracaoVisualDoScanner":
|
||||
"""Lê o mesmo JSON produzido pelo painel, sem vazar detalhes de adapters."""
|
||||
return cls(
|
||||
intervalo_em_segundos=float(dados.get("intervalo_em_segundos", 1)),
|
||||
faixas_de_video=tuple(sorted(set(int(item) for item in dados.get("faixas_de_video", ())))),
|
||||
faixas_de_audio=tuple(sorted(set(int(item) for item in dados.get("faixas_de_audio", ())))),
|
||||
recursos_vision=frozenset(dados.get("recursos_vision", RECURSOS_VISION)),
|
||||
detectar_cenas=bool(dados.get("detectar_cenas", True)),
|
||||
analisar_continuidade=bool(dados.get("analisar_continuidade", True)),
|
||||
preparar_reenquadramento=bool(dados.get("preparar_reenquadramento", False)),
|
||||
interpretar_cena=bool(dados.get("interpretar_cena", True)),
|
||||
)
|
||||
|
||||
def para_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"versao": 1,
|
||||
"intervalo_em_segundos": self.intervalo_em_segundos,
|
||||
"faixas_de_video": list(self.faixas_de_video),
|
||||
"faixas_de_audio": list(self.faixas_de_audio),
|
||||
"recursos_vision": sorted(self.recursos_vision),
|
||||
"detectar_cenas": self.detectar_cenas,
|
||||
"analisar_continuidade": self.analisar_continuidade,
|
||||
"preparar_reenquadramento": self.preparar_reenquadramento,
|
||||
"interpretar_cena": self.interpretar_cena,
|
||||
}
|
||||
@@ -14,7 +14,10 @@ class ContextoDeAnalise:
|
||||
transcricoes: dict[str, list[Any]] = field(default_factory=dict)
|
||||
caracteristicas_visuais: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
cenas: list[Any] = field(default_factory=list)
|
||||
cenas_visuais: list[Any] = field(default_factory=list)
|
||||
eventos: list[Any] = field(default_factory=list)
|
||||
arquivos: dict[str, Any] = field(default_factory=dict)
|
||||
metadados_dos_arquivos: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class Analisador(Protocol):
|
||||
@@ -42,5 +45,5 @@ class AnalisadorDeTimeline:
|
||||
def __init__(self, pipeline: PipelineDoScanner) -> None:
|
||||
self.pipeline = pipeline
|
||||
|
||||
def analisar(self) -> ContextoDeAnalise:
|
||||
return self.pipeline.executar(ContextoDeAnalise())
|
||||
def analisar(self, timeline: Timeline | None = None) -> ContextoDeAnalise:
|
||||
return self.pipeline.executar(ContextoDeAnalise(timeline=timeline))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .descoberta_da_timeline import DescobertaDaTimeline
|
||||
from .descoberta_de_arquivos import ArquivoDescoberto, DescobertaDeArquivos, LeitorLocalDeArquivos
|
||||
|
||||
__all__ = ["DescobertaDaTimeline"]
|
||||
__all__ = ["ArquivoDescoberto", "DescobertaDaTimeline", "DescobertaDeArquivos", "LeitorLocalDeArquivos"]
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from ...dominio import Clipe
|
||||
from ...scanner.modelos import ErroDeAnalise
|
||||
from ..coordenacao import ContextoDeAnalise
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArquivoDescoberto:
|
||||
"""Resultado da validação do arquivo associado a um clipe."""
|
||||
|
||||
caminho: Path
|
||||
existe: bool
|
||||
acessivel: bool
|
||||
tipo_de_midia: str | None = None
|
||||
tamanho_em_bytes: int | None = None
|
||||
|
||||
@property
|
||||
def offline(self) -> bool:
|
||||
return not self.existe or not self.acessivel
|
||||
|
||||
|
||||
class LeitorDeArquivos(Protocol):
|
||||
def descobrir(self, caminho: str | Path) -> ArquivoDescoberto: ...
|
||||
|
||||
|
||||
class LeitorLocalDeArquivos:
|
||||
"""Adapter que resolve e valida caminhos no sistema de arquivos local."""
|
||||
|
||||
def descobrir(self, caminho: str | Path) -> ArquivoDescoberto:
|
||||
caminho_resolvido = Path(caminho).expanduser().resolve(strict=False)
|
||||
existe = caminho_resolvido.is_file()
|
||||
acessivel = existe
|
||||
tamanho = None
|
||||
if existe:
|
||||
try:
|
||||
tamanho = caminho_resolvido.stat().st_size
|
||||
with caminho_resolvido.open("rb"):
|
||||
pass
|
||||
except (OSError, PermissionError):
|
||||
acessivel = False
|
||||
return ArquivoDescoberto(
|
||||
caminho=caminho_resolvido,
|
||||
existe=existe,
|
||||
acessivel=acessivel,
|
||||
tipo_de_midia=caminho_resolvido.suffix.lower().lstrip(".") or None,
|
||||
tamanho_em_bytes=tamanho,
|
||||
)
|
||||
|
||||
|
||||
class DescobertaDeArquivos:
|
||||
"""Relaciona os clipes da timeline aos arquivos físicos de origem."""
|
||||
|
||||
nome = "descoberta_de_arquivos"
|
||||
|
||||
def __init__(self, leitor: LeitorDeArquivos | None = None) -> None:
|
||||
self.leitor = leitor or LeitorLocalDeArquivos()
|
||||
|
||||
def executar(self, contexto: ContextoDeAnalise) -> ContextoDeAnalise:
|
||||
if contexto.timeline is None:
|
||||
return contexto
|
||||
|
||||
vistos: dict[Path, str] = {}
|
||||
for clipe in self._clipes(contexto):
|
||||
if not clipe.arquivo:
|
||||
clipe.offline = True
|
||||
self._registrar_erro(contexto, "arquivo_ausente", "Clipe sem sourceFile.", clipe)
|
||||
continue
|
||||
try:
|
||||
descoberto = self.leitor.descobrir(clipe.arquivo)
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
clipe.offline = True
|
||||
self._registrar_erro(contexto, "arquivo_inacessivel", str(exc), clipe)
|
||||
continue
|
||||
|
||||
clipe.arquivo = str(descoberto.caminho)
|
||||
clipe.offline = descoberto.offline
|
||||
contexto.arquivos[clipe.identificador] = descoberto
|
||||
if descoberto.offline:
|
||||
self._registrar_erro(contexto, "arquivo_inacessivel", "Arquivo não encontrado ou sem permissão de leitura.", clipe)
|
||||
elif descoberto.caminho in vistos:
|
||||
contexto.avisos.append(
|
||||
f"Arquivo duplicado entre os clipes {vistos[descoberto.caminho]} e {clipe.identificador}."
|
||||
)
|
||||
else:
|
||||
vistos[descoberto.caminho] = clipe.identificador
|
||||
return contexto
|
||||
|
||||
@staticmethod
|
||||
def _clipes(contexto: ContextoDeAnalise) -> list[Clipe]:
|
||||
return [clipe for faixa in contexto.timeline.faixas for clipe in faixa.clipes]
|
||||
|
||||
@staticmethod
|
||||
def _registrar_erro(contexto: ContextoDeAnalise, codigo: str, mensagem: str, clipe: Clipe) -> None:
|
||||
contexto.erros.append(str(ErroDeAnalise(codigo, mensagem, f"clipe[{clipe.identificador}].sourceFile")))
|
||||
@@ -0,0 +1,40 @@
|
||||
from typing import Protocol
|
||||
|
||||
from ..integracoes.midia.extracao_de_metadados import (
|
||||
ExtracaoDeMetadados as ExtratorDeMetadados,
|
||||
MetadadosDoArquivo,
|
||||
)
|
||||
from .coordenacao import ContextoDeAnalise
|
||||
|
||||
|
||||
class ExtratorDeMetadadosProtocol(Protocol):
|
||||
def extrair(self, arquivo: str) -> MetadadosDoArquivo: ...
|
||||
|
||||
|
||||
class ExtracaoDeMetadados:
|
||||
"""Enriquece cada clipe com os metadados técnicos do seu arquivo."""
|
||||
|
||||
nome = "extracao_de_metadados"
|
||||
|
||||
def __init__(self, extrator: ExtratorDeMetadadosProtocol | None = None) -> None:
|
||||
self.extrator = extrator or ExtratorDeMetadados()
|
||||
|
||||
def executar(self, contexto: ContextoDeAnalise) -> ContextoDeAnalise:
|
||||
for identificador, arquivo in contexto.arquivos.items():
|
||||
if arquivo.offline:
|
||||
continue
|
||||
try:
|
||||
metadados = self.extrator.extrair(str(arquivo.caminho))
|
||||
contexto.metadados_dos_arquivos[identificador] = metadados
|
||||
for faixa in contexto.timeline.faixas if contexto.timeline else []:
|
||||
for clipe in faixa.clipes:
|
||||
if clipe.identificador == identificador:
|
||||
clipe.metadados["arquivo"] = metadados
|
||||
break
|
||||
except Exception as exc:
|
||||
# Uma mídia inválida não deve descartar os resultados dos demais clipes.
|
||||
contexto.erros.append(f"metadados_indisponiveis: {arquivo.caminho}: {exc}")
|
||||
return contexto
|
||||
|
||||
|
||||
__all__ = ["ExtracaoDeMetadados", "MetadadosDoArquivo"]
|
||||
@@ -28,12 +28,32 @@ class ResultadoDaAnalise:
|
||||
avisos: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PalavraDeTranscricao:
|
||||
texto: str
|
||||
inicio: float
|
||||
fim: float
|
||||
confianca: float | None = None
|
||||
falante: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.inicio < 0 or self.fim < self.inicio:
|
||||
raise ValueError("Intervalo de palavra inválido.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SegmentoDeTranscricao:
|
||||
inicio: float
|
||||
fim: float
|
||||
texto: str
|
||||
confianca: float | None = None
|
||||
palavras: tuple[PalavraDeTranscricao, ...] = ()
|
||||
emocao: str | None = None
|
||||
confianca_emocao: float | None = None
|
||||
caracteristicas_acusticas: dict[str, float] = field(default_factory=dict)
|
||||
falante: str | None = None
|
||||
voz_aparente: str | None = None
|
||||
confianca_voz: float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.inicio < 0 or self.fim < self.inicio:
|
||||
@@ -54,3 +74,38 @@ class Evento:
|
||||
inicio: float
|
||||
fim: float
|
||||
confianca: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvidenciaVisual:
|
||||
"""Fato visual normalizado, independente da biblioteca que o produziu."""
|
||||
|
||||
tipo: str
|
||||
inicio: float
|
||||
fim: float
|
||||
valor: dict[str, Any]
|
||||
confianca: float | None = None
|
||||
provider: str = ""
|
||||
modelo: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.inicio < 0 or self.fim < self.inicio:
|
||||
raise ValueError("Intervalo de evidência visual inválido.")
|
||||
|
||||
|
||||
ObservacaoVisual = EvidenciaVisual
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CenaVisual:
|
||||
"""Intervalo visual enriquecido com as observações que o sustentam."""
|
||||
|
||||
identificador_do_clipe: str
|
||||
inicio: float
|
||||
fim: float
|
||||
observacoes: tuple[EvidenciaVisual, ...] = ()
|
||||
referencias_de_cena: tuple[float, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.inicio < 0 or self.fim < self.inicio:
|
||||
raise ValueError("Intervalo de cena visual inválido.")
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Exportação estruturada de uma leitura visual de clipe."""
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from ..dominio import Clipe
|
||||
from .visual import ResultadoVisualDoClipe
|
||||
|
||||
|
||||
VERSAO_DO_RELATORIO_VISUAL = "1.0"
|
||||
|
||||
|
||||
def gerar_relatorio_visual(clipe: Clipe, resultado: ResultadoVisualDoClipe,
|
||||
destino: str | Path, intervalo_de_amostragem: float) -> Path:
|
||||
"""Grava um JSON autocontido, com fatos por frame e fatos temporais separados."""
|
||||
if intervalo_de_amostragem <= 0:
|
||||
raise ValueError("intervalo_de_amostragem deve ser positivo.")
|
||||
caminho = Path(destino)
|
||||
caminho.parent.mkdir(parents=True, exist_ok=True)
|
||||
caminho.write_text(json.dumps(_dados_do_relatorio(clipe, resultado, intervalo_de_amostragem),
|
||||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return caminho
|
||||
|
||||
|
||||
def gerar_resumo_visual_markdown(clipe: Clipe, resultado: ResultadoVisualDoClipe,
|
||||
destino: str | Path, intervalo_de_amostragem: float) -> Path:
|
||||
"""Grava uma visão humana do mesmo resultado exportado em JSON."""
|
||||
caminho = Path(destino)
|
||||
caminho.parent.mkdir(parents=True, exist_ok=True)
|
||||
dados = _dados_do_relatorio(clipe, resultado, intervalo_de_amostragem)
|
||||
linhas = [f"# Relatório visual — {clipe.nome}", "",
|
||||
f"- Clipe: `{clipe.identificador}`",
|
||||
f"- Origem: `{clipe.arquivo}`",
|
||||
f"- Timeline: {clipe.intervalo_na_timeline.inicio:.3f}s–{clipe.intervalo_na_timeline.fim:.3f}s",
|
||||
f"- Amostragem: a cada {intervalo_de_amostragem:g} segundo(s)", "",
|
||||
"## Observações por frame", ""]
|
||||
for frame in dados["observacoes_por_frame"]:
|
||||
tipos = ", ".join(item["tipo"] for item in frame["evidencias"])
|
||||
linhas.extend([f"### Origem {frame['timestamp_na_origem']:.3f}s", "", tipos or "Sem evidências.", ""])
|
||||
linhas.extend(["## Continuidade", ""])
|
||||
for evidencia in dados["evidencias_temporais"]:
|
||||
linhas.append(f"- {evidencia['tipo']}: {evidencia['inicio']:.3f}s–{evidencia['fim']:.3f}s — "
|
||||
f"`{json.dumps(evidencia['valor'], ensure_ascii=False)}`")
|
||||
caminho.write_text("\n".join(linhas) + "\n", encoding="utf-8")
|
||||
return caminho
|
||||
|
||||
|
||||
def _dados_do_relatorio(clipe: Clipe, resultado: ResultadoVisualDoClipe,
|
||||
intervalo_de_amostragem: float) -> dict:
|
||||
por_frame = defaultdict(list)
|
||||
temporais = []
|
||||
for evidencia in resultado.evidencias:
|
||||
item = asdict(evidencia)
|
||||
if evidencia.inicio == evidencia.fim:
|
||||
por_frame[evidencia.inicio].append(item)
|
||||
else:
|
||||
temporais.append(item)
|
||||
origem = clipe.intervalo_na_origem
|
||||
return {
|
||||
"versao": VERSAO_DO_RELATORIO_VISUAL,
|
||||
"gerado_em": datetime.now(timezone.utc).isoformat(),
|
||||
"clipe": {
|
||||
"identificador": clipe.identificador,
|
||||
"nome": clipe.nome,
|
||||
"arquivo_original": clipe.arquivo,
|
||||
"intervalo_na_timeline": asdict(clipe.intervalo_na_timeline),
|
||||
"intervalo_na_origem": asdict(origem) if origem else None,
|
||||
},
|
||||
"amostragem": {"intervalo_em_segundos": intervalo_de_amostragem},
|
||||
"observacoes_por_frame": [
|
||||
{"timestamp_na_origem": timestamp, "evidencias": evidencias}
|
||||
for timestamp, evidencias in sorted(por_frame.items())
|
||||
],
|
||||
"evidencias_temporais": temporais,
|
||||
"cenas": [asdict(cena) for cena in resultado.cenas_visuais],
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Converte a transcrição existente em falas ordenadas para a análise.
|
||||
|
||||
O módulo de retakes não realiza transcrição. Ele recebe a saída do
|
||||
``TranscricaoDaTimeline`` (lista de ``TranscricaoDoClipe``) e a converte
|
||||
em ``Fala``s ordenadas ao longo da timeline, preservando o vínculo com o
|
||||
segmento/clipe de origem e as palavras alinhadas quando disponíveis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
from ..transcricao_da_timeline import TranscricaoDoClipe
|
||||
from .modelos_de_retakes import Fala
|
||||
|
||||
# Sinais de erro mais comuns vindos dos providers de transcrição.
|
||||
_PALAVRAS_DE_ERRO = {"", "...", "…"}
|
||||
|
||||
_VOYELS = "aeiouáéíóúâêôãõàèìòù"
|
||||
_CONSOANTES = "bcdfghjklmnpqrstvwxyz"
|
||||
_PADRAO_SILABA = re.compile(
|
||||
rf"([{_VOYELS}][^aeiouáéíóúâêôãõàèìòù]*)|([{_CONSOANTES}]+[aeiouáéíóúâêôãõàèìòù]?)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _normalizar(texto: str) -> str:
|
||||
"""Minúsculas, sem pontuação e sem espaços duplicados."""
|
||||
sem_pontuacao = re.sub(r"[^\w\s]", " ", texto)
|
||||
return " ".join(sem_pontuacao.lower().split())
|
||||
|
||||
|
||||
def _prefixo_comum(a: list[str], b: list[str]) -> int:
|
||||
n = 0
|
||||
for x, y in zip(a, b):
|
||||
if x != y:
|
||||
break
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def _sucesso_de_silabas(a: list[str], b: list[str]) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
silabas_a = [_PADRAO_SILABA.findall(p)[0][0] for p in a]
|
||||
silabas_b = [_PADRAO_SILABA.findall(p)[0][0] for p in b]
|
||||
n = _prefixo_comum(silabas_a, silabas_b)
|
||||
return n / max(len(silabas_a), len(silabas_b))
|
||||
|
||||
|
||||
def _e_ruido(texto: str) -> bool:
|
||||
texto_limpo = _normalizar(texto)
|
||||
if texto_limpo in _PALAVRAS_DE_ERRO:
|
||||
return True
|
||||
# Fala em branco ou "vazia" por provedor.
|
||||
return not texto_limpo
|
||||
|
||||
|
||||
class AdaptadorDeFalas:
|
||||
"""Transforma a transcrição da timeline em falas prontas para análise.
|
||||
|
||||
Recebe uma coleção de ``TranscricaoDoClipe`` (uma por faixa de áudio do
|
||||
vídeo) e devolve as falas ordenadas por tempo. O ``video_id`` é um rótulo
|
||||
informado pelo chamador; quando ausente, usa o identificador da timeline.
|
||||
"""
|
||||
|
||||
def __init__(self, video_id: str | None = None, faixa_id: str | None = None) -> None:
|
||||
self.video_id = video_id
|
||||
self.faixa_id = faixa_id
|
||||
|
||||
def converter(
|
||||
self,
|
||||
transcricoes: Iterable[TranscricaoDoClipe],
|
||||
video_id: str | None = None,
|
||||
faixa_id: str | None = None,
|
||||
) -> list[Fala]:
|
||||
video_id = video_id or self.video_id or "video"
|
||||
faixa_id = faixa_id or self.faixa_id or "faixa"
|
||||
|
||||
falas: list[Fala] = []
|
||||
for transcricao in transcricoes:
|
||||
segmento_id = transcricao.identificador_do_clipe
|
||||
for segmento in transcricao.segmentos:
|
||||
if _e_ruido(segmento.texto):
|
||||
continue
|
||||
falas.append(Fala(
|
||||
id=f"{segmento_id}:{segmento.inicio:.3f}",
|
||||
video_id=video_id,
|
||||
faixa_id=faixa_id,
|
||||
segmento_id=segmento_id,
|
||||
ordem=-1, # preenchida após a ordenação
|
||||
inicio=segmento.inicio,
|
||||
fim=segmento.fim,
|
||||
texto=segmento.texto,
|
||||
texto_normalizado=_normalizar(segmento.texto),
|
||||
palavras=segmento.palavras,
|
||||
falante=segmento.falante,
|
||||
))
|
||||
|
||||
falas.sort(key=lambda item: (item.inicio, item.fim))
|
||||
for indice, fala in enumerate(falas):
|
||||
falas[indice] = Fala(
|
||||
id=fala.id,
|
||||
video_id=fala.video_id,
|
||||
faixa_id=fala.faixa_id,
|
||||
segmento_id=fala.segmento_id,
|
||||
ordem=indice,
|
||||
inicio=fala.inicio,
|
||||
fim=fala.fim,
|
||||
texto=fala.texto,
|
||||
texto_normalizado=fala.texto_normalizado,
|
||||
palavras=fala.palavras,
|
||||
falante=fala.falante,
|
||||
)
|
||||
return falas
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Agrupamento de falas que representam retakes da mesma fala ou ideia.
|
||||
|
||||
Trabalha com uma janela temporal: compara cada fala com as ``n`` seguintes
|
||||
(e só as da mesma faixa de áudio), evitando agrupar frases iguais que
|
||||
aparecem em partes muito distantes do vídeo. Não decide a classificação —
|
||||
apenas forma grupos candidatos e reúne as métricas.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from .analisador_de_intervalos import AnalisadorDeIntervalos
|
||||
from .comparador_de_falas import ComparadorDeFalas
|
||||
from .comparador_semantico import ComparadorSemantico
|
||||
from .detector_de_reinicios import DetectorDeReinicios
|
||||
from .modelos_de_retakes import (
|
||||
AgrupamentoDeFalas,
|
||||
Fala,
|
||||
ParAgrupado,
|
||||
ResultadoDoIntervalo,
|
||||
SinalDeReinicio,
|
||||
)
|
||||
|
||||
# Limiares de candidatura de um par a pertencer a um grupo de retake.
|
||||
_SIMILARIDADE_MINIMA_CANDIDATO = 0.55
|
||||
_SIMILARIDADE_FORTE = 0.75
|
||||
_PROXIMIDADE_AUXILIAR = 0.60
|
||||
|
||||
# Janela padrão de falas a serem comparadas com cada uma.
|
||||
_JANELA_PADRAO = 5
|
||||
|
||||
# Região de dúvida que dispara a comparação semântica.
|
||||
_ZONA_DE_DUVIDA_INFERIOR = 0.60
|
||||
_ZONA_DE_DUVIDA_SUPERIOR = 0.85
|
||||
|
||||
|
||||
class AgrupadorDeTakes:
|
||||
"""Gera grupos candidatos conectando falas semelhantes e próximas."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
comparador: ComparadorDeFalas,
|
||||
comparador_semantico: ComparadorSemantico,
|
||||
analisador_de_intervalos: AnalisadorDeIntervalos,
|
||||
detector_de_reinicios: DetectorDeReinicios,
|
||||
janela: int = _JANELA_PADRAO,
|
||||
) -> None:
|
||||
self.comparador = comparador
|
||||
self.comparador_semantico = comparador_semantico
|
||||
self.analisador_de_intervalos = analisador_de_intervalos
|
||||
self.detector_de_reinicios = detector_de_reinicios
|
||||
self.janela = janela
|
||||
|
||||
def agrupar(self, falas: Iterable[Fala]) -> list[AgrupamentoDeFalas]:
|
||||
falas = sorted(falas, key=lambda item: (item.ordem, item.inicio))
|
||||
sinais = {sinal.fala_id: sinal for sinal in self.detector_de_reinicios.detectar(falas)}
|
||||
arestas: list[tuple[int, int, ParAgrupado]] = []
|
||||
|
||||
for indice, fala_a in enumerate(falas):
|
||||
limite = min(len(falas), indice + 1 + self.janela)
|
||||
for jindice in range(indice + 1, limite):
|
||||
fala_b = falas[jindice]
|
||||
if fala_a.faixa_id != fala_b.faixa_id:
|
||||
continue
|
||||
par = self._avaliar_par(fala_a, fala_b, sinais)
|
||||
if par is not None:
|
||||
arestas.append((indice, jindice, par))
|
||||
|
||||
return self._agrupar_por_arestas(falas, arestas, sinais)
|
||||
|
||||
def _avaliar_par(
|
||||
self,
|
||||
fala_a: Fala,
|
||||
fala_b: Fala,
|
||||
sinais: dict[str, SinalDeReinicio],
|
||||
) -> ParAgrupado | None:
|
||||
comparacao = self.comparador.comparar(fala_a, fala_b)
|
||||
intervalo = self.analisador_de_intervalos.analisar(fala_a, fala_b)
|
||||
|
||||
tem_reinicio = sinais.get(fala_b.id) is not None
|
||||
semantica = 0.0
|
||||
|
||||
# Comparação semântica só na zona de dúvida, para evitar custo.
|
||||
texto = comparacao.similaridade_final
|
||||
if _ZONA_DE_DUVIDA_INFERIOR <= texto <= _ZONA_DE_DUVIDA_SUPERIOR:
|
||||
semantica = self.comparador_semantico.comparar(fala_a, fala_b)
|
||||
|
||||
melhor = max(comparacao.similaridade_final, semantica)
|
||||
candidato = (
|
||||
melhor >= _SIMILARIDADE_FORTE
|
||||
or (melhor >= _SIMILARIDADE_MINIMA_CANDIDATO
|
||||
and intervalo.proximidade_temporal >= _PROXIMIDADE_AUXILIAR
|
||||
and (tem_reinicio or intervalo.indicio_de_nova_tentativa))
|
||||
)
|
||||
if candidato:
|
||||
return ParAgrupado(fala_a, fala_b, comparacao, intervalo,
|
||||
round(semantica, 4))
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _agrupar_por_arestas(
|
||||
falas: list[Fala],
|
||||
arestas: list[tuple[int, int, ParAgrupado]],
|
||||
sinais: dict[str, SinalDeReinicio],
|
||||
) -> list[AgrupamentoDeFalas]:
|
||||
pai = list(range(len(falas)))
|
||||
|
||||
def encontrar(x: int) -> int:
|
||||
while pai[x] != x:
|
||||
pai[x] = pai[pai[x]]
|
||||
x = pai[x]
|
||||
return x
|
||||
|
||||
arestas.sort(key=lambda item: (item[0], item[1]))
|
||||
for a, b, _ in arestas:
|
||||
raiz_a, raiz_b = encontrar(a), encontrar(b)
|
||||
if raiz_a != raiz_b:
|
||||
pai[raiz_b] = raiz_a
|
||||
|
||||
componentes: dict[int, list[int]] = {}
|
||||
for indice in range(len(falas)):
|
||||
componentes.setdefault(encontrar(indice), []).append(indice)
|
||||
|
||||
grupos: list[AgrupamentoDeFalas] = []
|
||||
for inds in componentes.values():
|
||||
if len(inds) < 2:
|
||||
continue
|
||||
inds.sort()
|
||||
fala_ids = tuple(falas[indice].id for indice in inds)
|
||||
pares_por_chave: dict[tuple[str, str], ParAgrupado] = {
|
||||
(par.fala_a.id, par.fala_b.id): par
|
||||
for _, _, par in arestas
|
||||
if par.fala_a.id in fala_ids and par.fala_b.id in fala_ids
|
||||
}
|
||||
pares: list[ParAgrupado] = []
|
||||
for a, b in zip(inds, inds[1:]):
|
||||
chave = (falas[a].id, falas[b].id)
|
||||
par = pares_por_chave.get(chave)
|
||||
if par is None:
|
||||
par = pares_por_chave.get((falas[b].id, falas[a].id))
|
||||
if par is not None:
|
||||
pares.append(par)
|
||||
sinais_do_grupo = tuple(
|
||||
sinais[fala_id] for fala_id in fala_ids if fala_id in sinais)
|
||||
if pares:
|
||||
grupos.append(AgrupamentoDeFalas(fala_ids, tuple(pares), sinais_do_grupo))
|
||||
return grupos
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Análise da relação temporal entre falas.
|
||||
|
||||
Verifica quanto tempo há entre duas falas, se estão na mesma faixa/segmento
|
||||
e se a proximidade sugere uma nova tentativa. Um intervalo muito grande
|
||||
reduz a confiança de que seja um retake imediato.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .modelos_de_retakes import Fala, ResultadoDoIntervalo
|
||||
|
||||
# A partir de quantos segundos o intervalo passa a não sugerir retake.
|
||||
_INTERVALO_MAXIMO = 12.0
|
||||
|
||||
|
||||
class AnalisadorDeIntervalos:
|
||||
"""Calcula a proximidade temporal e o indício de nova tentativa."""
|
||||
|
||||
def __init__(self, intervalo_maximo: float = _INTERVALO_MAXIMO) -> None:
|
||||
self.intervalo_maximo = intervalo_maximo
|
||||
|
||||
def analisar(self, fala_a: Fala, fala_b: Fala) -> ResultadoDoIntervalo:
|
||||
intervalo = max(0.0, fala_b.inicio - fala_a.fim)
|
||||
mesma_faixa = fala_a.faixa_id == fala_b.faixa_id
|
||||
mesmo_segmento = mesma_faixa and fala_a.segmento_id == fala_b.segmento_id
|
||||
|
||||
if intervalo >= self.intervalo_maximo:
|
||||
proximidade = 0.0
|
||||
elif intervalo <= 0:
|
||||
proximidade = 1.0
|
||||
else:
|
||||
# Decai suavemente com o intervalo: 1.0 → ~0 em 12s.
|
||||
proximidade = max(0.0, 1.0 - intervalo / self.intervalo_maximo)
|
||||
|
||||
indicio = mesma_faixa and intervalo <= self.intervalo_maximo and proximidade >= 0.5
|
||||
return ResultadoDoIntervalo(
|
||||
intervalo_em_segundos=round(intervalo, 3),
|
||||
mesmo_segmento=mesmo_segmento,
|
||||
proximidade_temporal=round(proximidade, 4),
|
||||
indicio_de_nova_tentativa=bool(indicio),
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Comparação textual entre falas.
|
||||
|
||||
Responsável pelos algoritmos de similaridade: Jaccard (conjunto de
|
||||
palavras), sequência (ordem das palavras via maior subsequência comum) e
|
||||
similaridade do início/fim da frase. Não decide nada sozinho — apenas
|
||||
produz métricas para o classificador.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .modelos_de_retakes import Fala, ResultadoDaComparacao
|
||||
|
||||
|
||||
def _normalizar(texto: str) -> str:
|
||||
"""Minúsculas, sem pontuação e sem espaços duplicados."""
|
||||
sem_pontuacao = re.sub(r"[^\w\s]", " ", texto)
|
||||
return " ".join(sem_pontuacao.lower().split())
|
||||
|
||||
|
||||
def _lcs(a: list[str], b: list[str]) -> int:
|
||||
"""Tamanho da maior subsequência comum preservando a ordem."""
|
||||
anterior = [0] * (len(b) + 1)
|
||||
for palavra_a in a:
|
||||
atual = [0] * (len(b) + 1)
|
||||
for j, palavra_b in enumerate(b):
|
||||
if palavra_a == palavra_b:
|
||||
atual[j + 1] = anterior[j] + 1
|
||||
else:
|
||||
atual[j + 1] = max(atual[j], anterior[j + 1])
|
||||
anterior = atual
|
||||
return anterior[-1]
|
||||
|
||||
|
||||
class ComparadorDeFalas:
|
||||
"""Compara duas falas e calcula as métricas de similaridade textual."""
|
||||
|
||||
@staticmethod
|
||||
def normalizar(texto: str) -> str:
|
||||
return _normalizar(texto)
|
||||
|
||||
@staticmethod
|
||||
def _palavras_de(fala: Fala) -> list[str]:
|
||||
return fala.texto_normalizado.split() or _normalizar(fala.texto).split()
|
||||
|
||||
def comparar(self, fala_a: Fala, fala_b: Fala) -> ResultadoDaComparacao:
|
||||
palavras_a = self._palavras_de(fala_a)
|
||||
palavras_b = self._palavras_de(fala_b)
|
||||
|
||||
if not palavras_a or not palavras_b:
|
||||
return ResultadoDaComparacao(fala_a.id, fala_b.id)
|
||||
|
||||
# 1. Jaccard — conjunto de palavras (rápido, ótimo candidato).
|
||||
conjunto_a = set(palavras_a)
|
||||
conjunto_b = set(palavras_b)
|
||||
intersecao = len(conjunto_a & conjunto_b)
|
||||
uniao = len(conjunto_a | conjunto_b)
|
||||
jaccard = intersecao / uniao if uniao else 0.0
|
||||
|
||||
# 2. Sequência — ordem das palavras via LCS normalizada.
|
||||
lcs = _lcs(palavras_a, palavras_b)
|
||||
sequencia = lcs / max(len(palavras_a), len(palavras_b))
|
||||
|
||||
# 3. Início e final da frase.
|
||||
inicio = self._similaridade_de_prefijo(palavras_a, palavras_b)
|
||||
final = self._similaridade_de_sufixo(palavras_a, palavras_b)
|
||||
|
||||
# 4. Similaridade final ponderada.
|
||||
fim = 0.45 * sequencia + 0.30 * jaccard + 0.15 * inicio + 0.10 * final
|
||||
return ResultadoDaComparacao(
|
||||
fala_a_id=fala_a.id,
|
||||
fala_b_id=fala_b.id,
|
||||
similaridade_jaccard=round(jaccard, 4),
|
||||
similaridade_de_sequencia=round(sequencia, 4),
|
||||
similaridade_do_inicio=round(inicio, 4),
|
||||
similaridade_do_final=round(final, 4),
|
||||
similaridade_final=round(min(1.0, fim), 4),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _similaridade_de_prefijo(a: list[str], b: list[str]) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
n = 0
|
||||
for x, y in zip(a, b):
|
||||
if x != y:
|
||||
break
|
||||
n += 1
|
||||
return n / max(len(a), len(b))
|
||||
|
||||
@staticmethod
|
||||
def _similaridade_de_sufixo(a: list[str], b: list[str]) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
n = 0
|
||||
for x, y in zip(reversed(a), reversed(b)):
|
||||
if x != y:
|
||||
break
|
||||
n += 1
|
||||
return n / max(len(a), len(b))
|
||||
|
||||
@staticmethod
|
||||
def prefixo_comum_em_palavras(texto_a: str, texto_b: str) -> int:
|
||||
a = _normalizar(texto_a).split()
|
||||
b = _normalizar(texto_b).split()
|
||||
n = 0
|
||||
for x, y in zip(a, b):
|
||||
if x != y:
|
||||
break
|
||||
n += 1
|
||||
return n
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Comparação semântica entre falas (opcional).
|
||||
|
||||
A similaridade textual não cobre casos em que as palavras são diferentes
|
||||
mas a ideia é a mesma. Este módulo define um protocolo para providers de
|
||||
embeddings e uma implementação local (via Hugging Face Transformers), além
|
||||
de um fallback puramente lexical usado quando nenhum provider está
|
||||
disponível.
|
||||
|
||||
O `ComparadorSemantico` nunca decide sozinho que há retake — apenas
|
||||
fornece uma métrica adicional para o classificador.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Protocol
|
||||
|
||||
from .modelos_de_retakes import Fala
|
||||
|
||||
|
||||
class ProviderDeSimilaridadeSemantica(Protocol):
|
||||
"""Contrato para quem calcula similaridade semântica entre dois textos."""
|
||||
|
||||
def similaridade(self, texto_a: str, texto_b: str) -> float: ...
|
||||
|
||||
|
||||
class ComparadorLexicalSemantico:
|
||||
"""Fallback puramente lexical para quando não há embeddings.
|
||||
|
||||
Reconstrói uma "similaridade semântica" a partir de palavras que
|
||||
compartilham a mesma raiz (stemming simples por prefixo comum) e de
|
||||
sinônimos frequentes em pt-PT. Destina-se a permitir executar a análise
|
||||
sem carregar modelos pesados.
|
||||
"""
|
||||
|
||||
_PARES_SINONIMOS = (
|
||||
({"mostrar", "explicar", "demonstrar", "apresentar"},),
|
||||
({"sistema", "programa", "aplicativo", "software"},),
|
||||
({"configurar", "configuracao", "instalar", "ajustar"},),
|
||||
)
|
||||
|
||||
def similaridade(self, texto_a: str, texto_b: str) -> float:
|
||||
palavras_a = {p for p in self._palavras(texto_a)}
|
||||
palavras_b = {p for p in self._palavras(texto_b)}
|
||||
if not palavras_a or not palavras_b:
|
||||
return 0.0
|
||||
|
||||
intersecao = 0.0
|
||||
for palavra_a in palavras_a:
|
||||
for palavra_b in palavras_b:
|
||||
if palavra_a == palavra_b:
|
||||
intersecao += 1.0
|
||||
elif self._mesma_raiz(palavra_a, palavra_b):
|
||||
intersecao += 0.7
|
||||
elif self._mesmo_sinonimo(palavra_a, palavra_b):
|
||||
intersecao += 0.6
|
||||
tam = max(len(palavras_a), len(palavras_b))
|
||||
return round(min(1.0, intersecao / tam), 4)
|
||||
|
||||
@staticmethod
|
||||
def _palavras(texto: str) -> list[str]:
|
||||
return [p for p in texto.lower().split()]
|
||||
|
||||
@staticmethod
|
||||
def _mesma_raiz(a: str, b: str) -> bool:
|
||||
raiz = min(len(a), len(b), 4)
|
||||
return raiz >= 4 and a[:raiz] == b[:raiz]
|
||||
|
||||
@staticmethod
|
||||
def _mesmo_sinonimo(a: str, b: str) -> bool:
|
||||
return any(a in grupo and b in grupo for grupo in ComparadorLexicalSemantico._PARES_SINONIMOS)
|
||||
|
||||
|
||||
class ComparadorSemantico:
|
||||
"""Calcula similaridade semântica usando um provider injetável.
|
||||
|
||||
Quando o provider é ``None``, cai no ``ComparadorLexicalSemantico``
|
||||
para não bloquear a análise na ausência de modelos locais.
|
||||
"""
|
||||
|
||||
def __init__(self, provider: ProviderDeSimilaridadeSemantica | None = None) -> None:
|
||||
self.provider = provider or ComparadorLexicalSemantico()
|
||||
|
||||
def comparar(self, fala_a: Fala, fala_b: Fala) -> float:
|
||||
return float(self.provider.similaridade(fala_a.texto, fala_b.texto))
|
||||
|
||||
|
||||
class ProviderDeSimilaridadeHuggingFace:
|
||||
"""Embeds os textos localmente com um modelo de embeddings.
|
||||
|
||||
O modelo é carregado de forma preguiçosa para não custar nada até ser
|
||||
de fato necessário (zona de dúvida do classificador).
|
||||
"""
|
||||
|
||||
def __init__(self, modelo: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2") -> None:
|
||||
self.modelo = modelo
|
||||
self._encoder = None
|
||||
|
||||
def similaridade(self, texto_a: str, texto_b: str) -> float:
|
||||
if self._encoder is None:
|
||||
from sentence_transformers import SentenceTransformer
|
||||
self._encoder = SentenceTransformer(self.modelo)
|
||||
embeddings = self._encoder.encode([texto_a, texto_b], normalize_embeddings=True)
|
||||
return float(_cosseno(embeddings[0], embeddings[1]))
|
||||
|
||||
|
||||
def _cosseno(a: list[float], b: list[float]) -> float:
|
||||
produto = sum(x * y for x, y in zip(a, b))
|
||||
norma_a = math.sqrt(sum(x * x for x in a))
|
||||
norma_b = math.sqrt(sum(y * y for y in b))
|
||||
if norma_a == 0 or norma_b == 0:
|
||||
return 0.0
|
||||
return max(0.0, min(1.0, produto / (norma_a * norma_b)))
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Detecção de sinais de reinício/recomeço de fala.
|
||||
|
||||
Analisa padrões de que a pessoa começou novamente uma ideia: repetição do
|
||||
início de uma frase, pausas longas, marcadores explícitos ("não", "pera",
|
||||
"vamos de novo", "desculpa") e repetição de palavras consecutivas.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Iterable
|
||||
|
||||
from .comparador_de_falas import ComparadorDeFalas
|
||||
from .modelos_de_retakes import Fala, SinalDeReinicio
|
||||
|
||||
_MARCADORES = (
|
||||
"não", "pera", "peraí", "pera ai", "espera", "vamos de novo", "de novo",
|
||||
"vamos recomeçar", "recomeçar", "desculpa", "desculpe", "deixa eu ver",
|
||||
"vou repetir", "esquece", "hmm", "hm", "uhm", "tá", "ta",
|
||||
)
|
||||
|
||||
_RE_MARCADOR = re.compile(
|
||||
r"\b(?:" + "|".join(re.escape(m) for m in _MARCADORES) + r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_PAUSA_LONGA = 1.2 # segundos a partir dos quais a pausa sugere recomeço.
|
||||
|
||||
|
||||
class DetectorDeReinicios:
|
||||
"""Encontra ``SinalDeReinicio`` em cada fala em relação à anterior."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
comparador: ComparadorDeFalas | None = None,
|
||||
pausa_longa: float = _PAUSA_LONGA,
|
||||
) -> None:
|
||||
self.comparador = comparador or ComparadorDeFalas()
|
||||
self.pausa_longa = pausa_longa
|
||||
|
||||
def detectar(self, falas: Iterable[Fala]) -> list[SinalDeReinicio]:
|
||||
falas = list(falas)
|
||||
sinais: list[SinalDeReinicio] = []
|
||||
for indice, fala in enumerate(falas):
|
||||
sinal = self._analisar(fala, falas[indice - 1] if indice > 0 else None)
|
||||
if sinal:
|
||||
sinais.append(sinal)
|
||||
return sinais
|
||||
|
||||
def _analisar(self, fala: Fala, anterior: Fala | None) -> SinalDeReinicio | None:
|
||||
evidencias: list[str] = []
|
||||
intensidades: list[float] = []
|
||||
palavras = fala.texto_normalizado.split()
|
||||
|
||||
# 1. Marcadores explícitos de recomeço no início.
|
||||
if palavras and _RE_MARCADOR.match(palavras[0]):
|
||||
evidencias.append("marcador explícito de recomeço")
|
||||
intensidades.append(0.9)
|
||||
|
||||
if anterior is None:
|
||||
if intensidades:
|
||||
return SinalDeReinicio(fala.id, "reinicio_explicito",
|
||||
round(max(intensidades), 3), evidencias)
|
||||
return None
|
||||
|
||||
# 2. Pausa longa antes da fala.
|
||||
pausa = fala.inicio - anterior.fim
|
||||
if pausa >= self.pausa_longa:
|
||||
evidencias.append(f"pausa de {pausa:.1f} segundos")
|
||||
intensidades.append(min(1.0, 0.5 + pausa / 8.0))
|
||||
|
||||
# 3. Repetição do início da frase anterior.
|
||||
prefixo = self.comparador.prefixo_comum_em_palavras(anterior.texto, fala.texto)
|
||||
if prefixo >= 3:
|
||||
evidencias.append(f"repetição das primeiras {prefixo} palavras")
|
||||
intensidades.append(min(1.0, 0.4 + prefixo * 0.08))
|
||||
|
||||
if not evidencias:
|
||||
return None
|
||||
|
||||
return SinalDeReinicio(
|
||||
fala_id=fala.id,
|
||||
tipo="repeticao_do_inicio" if any("repetição" in e for e in evidencias) else "reinicio_pos_pausa",
|
||||
intensidade=round(min(1.0, max(intensidades)), 3),
|
||||
evidencias=evidencias,
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Modelos de dados do submódulo de detecção de retakes.
|
||||
|
||||
Mantemos os mesmos princípios de ``scanner/modelos.py``: dataclasses
|
||||
imutáveis (``frozen=True``) com validação em ``__post_init__`` e sem
|
||||
dependência de implementações concretas de providers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..modelos import PalavraDeTranscricao
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Fala:
|
||||
"""Uma fala normalizada, já posicionada na timeline do vídeo.
|
||||
|
||||
Corresponde a um ``SegmentoDeTranscricao`` convertido pelo adaptador,
|
||||
mas carrega o contexto necessário para a análise temporal e de
|
||||
agrupamento: vídeo, faixa de áudio, número de sequência e o vínculo
|
||||
com o segmento/clipe de origem.
|
||||
"""
|
||||
|
||||
id: str
|
||||
video_id: str
|
||||
faixa_id: str
|
||||
segmento_id: str
|
||||
ordem: int
|
||||
inicio: float
|
||||
fim: float
|
||||
texto: str
|
||||
texto_normalizado: str = ""
|
||||
palavras: tuple[PalavraDeTranscricao, ...] = ()
|
||||
falante: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.inicio < 0 or self.fim < self.inicio:
|
||||
raise ValueError(f"Intervalo da fala {self.id} inválido.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SinalDeReinicio:
|
||||
"""Indício de que uma fala recomeçou uma ideia já iniciada."""
|
||||
|
||||
fala_id: str
|
||||
tipo: str
|
||||
intensidade: float
|
||||
evidencias: list[str] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not 0.0 <= self.intensidade <= 1.0:
|
||||
raise ValueError(f"Intensidade do reinício {self.fala_id} fora de [0, 1].")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResultadoDaComparacao:
|
||||
"""Resultado da comparação de duas falas, textual e por sequência."""
|
||||
|
||||
fala_a_id: str
|
||||
fala_b_id: str
|
||||
similaridade_jaccard: float = 0.0
|
||||
similaridade_de_sequencia: float = 0.0
|
||||
similaridade_do_inicio: float = 0.0
|
||||
similaridade_do_final: float = 0.0
|
||||
similaridade_final: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResultadoDoIntervalo:
|
||||
"""Relação temporal entre duas falas."""
|
||||
|
||||
intervalo_em_segundos: float
|
||||
mesmo_segmento: bool = False
|
||||
proximidade_temporal: float = 0.0
|
||||
indicio_de_nova_tentativa: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EvidenciaDeRetake:
|
||||
"""Uma evidência legível que sustenta a classificação de um grupo."""
|
||||
|
||||
tipo: str
|
||||
descricao: str
|
||||
valor: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TomadaDeRetake:
|
||||
"""Uma tomada (fala) pertencente a um grupo de retakes."""
|
||||
|
||||
ordem: int
|
||||
fala_id: str
|
||||
segmento_id: str
|
||||
inicio: float
|
||||
fim: float
|
||||
texto: str
|
||||
similaridade_com_anterior: float = 0.0
|
||||
confianca: float = 0.0
|
||||
|
||||
|
||||
def gerar_id_do_grupo() -> str:
|
||||
"""Gera um id curto e monótono para um grupo de retakes."""
|
||||
return f"retake_{uuid.uuid4().hex[:6]}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParAgrupado:
|
||||
"""Um par de falas que o agrupador juntou como candidato a retake."""
|
||||
|
||||
fala_a: Fala
|
||||
fala_b: Fala
|
||||
comparacao: ResultadoDaComparacao
|
||||
intervalo: ResultadoDoIntervalo
|
||||
similaridade_semantica: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgrupamentoDeFalas:
|
||||
"""Um grupo candidato formado pelo agrupador, antes da classificação."""
|
||||
|
||||
fala_ids: tuple[str, ...]
|
||||
pares: tuple[ParAgrupado, ...]
|
||||
sinais_de_reinicio: tuple[SinalDeReinicio, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GrupoDeRetake:
|
||||
"""Agrupa as tomadas que representam tentativas da mesma fala."""
|
||||
|
||||
id: str
|
||||
video_id: str
|
||||
faixa_id: str
|
||||
tipo: str
|
||||
confianca: float
|
||||
tomadas: list[TomadaDeRetake] = field(default_factory=list)
|
||||
evidencias: list[EvidenciaDeRetake] = field(default_factory=list)
|
||||
criado_em: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not 0.0 <= self.confianca <= 1.0:
|
||||
raise ValueError(f"Confiança do grupo {self.id} fora de [0, 1].")
|
||||
|
||||
@property
|
||||
def tomada_principal_id(self) -> str | None:
|
||||
if not self.tomadas:
|
||||
return None
|
||||
return max(self.tomadas, key=lambda item: item.confianca).fala_id
|
||||
|
||||
@property
|
||||
def fala_ids(self) -> list[str]:
|
||||
return [tomada.fala_id for tomada in self.tomadas]
|
||||
@@ -1,10 +1,11 @@
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from ..integracoes.midia import ExtracaoDeAudio
|
||||
from .modelos import SegmentoDeTranscricao
|
||||
from .modelos import PalavraDeTranscricao, SegmentoDeTranscricao
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -14,6 +15,7 @@ class TranscricaoDoClipe:
|
||||
inicio_na_timeline: float
|
||||
fim_na_timeline: float
|
||||
segmentos: list[SegmentoDeTranscricao]
|
||||
intervalo_na_origem: tuple[float, float] | None = None
|
||||
|
||||
@property
|
||||
def texto(self) -> str:
|
||||
@@ -21,39 +23,168 @@ class TranscricaoDoClipe:
|
||||
|
||||
|
||||
class TranscricaoDaTimeline:
|
||||
"""Transcreve somente os intervalos efetivamente usados na timeline."""
|
||||
"""Transcreve cada arquivo uma vez e projeta o resultado nos cortes.
|
||||
|
||||
A primeira versão suporta apenas mapeamento linear em velocidade normal.
|
||||
Os timestamps retornados pelo provider são sempre relativos ao arquivo
|
||||
original; somente a cópia materializada para cada clipe recebe timestamps
|
||||
da timeline.
|
||||
"""
|
||||
|
||||
def __init__(self, provider: Any, extrator: ExtracaoDeAudio | None = None,
|
||||
diretoria_de_trabalho: str | Path = ".transcricao") -> None:
|
||||
diretoria_de_trabalho: str | Path = ".transcricao",
|
||||
analisador_de_emocao: Any | None = None,
|
||||
diarizador: Any | None = None) -> None:
|
||||
self.provider = provider
|
||||
self.extrator = extrator or ExtracaoDeAudio()
|
||||
self.diretoria_de_trabalho = Path(diretoria_de_trabalho)
|
||||
self.analisador_de_emocao = analisador_de_emocao
|
||||
self.diarizador = diarizador
|
||||
|
||||
def executar(self, timeline: Any) -> list[TranscricaoDoClipe]:
|
||||
resultados: list[TranscricaoDoClipe] = []
|
||||
clipes: list[Any] = []
|
||||
for faixa in timeline.faixas:
|
||||
for clipe in faixa.clipes:
|
||||
if not clipe.arquivo or clipe.offline:
|
||||
continue
|
||||
intervalo = clipe.intervalo_na_timeline
|
||||
pasta = self.diretoria_de_trabalho / clipe.identificador
|
||||
origem = clipe.intervalo_na_origem
|
||||
partes = self.extrator.extrair_intervalo(
|
||||
clipe.arquivo, pasta, origem.inicio if origem else 0.0,
|
||||
origem.fim if origem else None,
|
||||
)
|
||||
segmentos: list[SegmentoDeTranscricao] = []
|
||||
for parte in partes:
|
||||
for segmento in self.provider.transcrever(type("Audio", (), {"arquivo": str(parte.caminho)})()):
|
||||
base_origem = origem.inicio if origem else 0.0
|
||||
inicio = max(intervalo.inicio, intervalo.inicio + (parte.inicio - base_origem) + segmento.inicio)
|
||||
fim = min(intervalo.fim, intervalo.inicio + (parte.inicio - base_origem) + segmento.fim)
|
||||
if inicio < intervalo.fim and fim > inicio:
|
||||
segmentos.append(SegmentoDeTranscricao(inicio, fim, segmento.texto, segmento.confianca))
|
||||
resultados.append(TranscricaoDoClipe(clipe.identificador, clipe.arquivo,
|
||||
intervalo.inicio, intervalo.fim, segmentos))
|
||||
clipes.append(clipe)
|
||||
|
||||
transcricoes: dict[str, list[SegmentoDeTranscricao]] = {}
|
||||
for clipe in clipes:
|
||||
chave = self._chave_do_arquivo(clipe.arquivo)
|
||||
if chave not in transcricoes:
|
||||
transcricoes[chave] = self._transcrever_arquivo(clipe.arquivo, chave)
|
||||
|
||||
resultados: list[TranscricaoDoClipe] = []
|
||||
vistos: set[tuple[str, float, float, float, float]] = set()
|
||||
for clipe in clipes:
|
||||
intervalo = clipe.intervalo_na_timeline
|
||||
origem = clipe.intervalo_na_origem
|
||||
inicio_origem = origem.inicio if origem else 0.0
|
||||
fim_origem = origem.fim if origem else float("inf")
|
||||
chave = (self._chave_do_arquivo(clipe.arquivo), inicio_origem, fim_origem,
|
||||
intervalo.inicio, intervalo.fim)
|
||||
# Vídeo e áudio vinculados podem representar o mesmo corte.
|
||||
if chave in vistos:
|
||||
continue
|
||||
vistos.add(chave)
|
||||
segmentos = []
|
||||
for segmento in transcricoes[chave[0]]:
|
||||
fim = min(segmento.fim, fim_origem)
|
||||
inicio = max(segmento.inicio, inicio_origem)
|
||||
if inicio < fim:
|
||||
palavras = tuple(
|
||||
PalavraDeTranscricao(
|
||||
palavra.texto,
|
||||
intervalo.inicio + max(palavra.inicio, inicio_origem) - inicio_origem,
|
||||
intervalo.inicio + min(palavra.fim, fim_origem) - inicio_origem,
|
||||
palavra.confianca,
|
||||
palavra.falante,
|
||||
)
|
||||
for palavra in segmento.palavras
|
||||
if palavra.inicio < fim_origem and palavra.fim > inicio_origem
|
||||
)
|
||||
segmentos.append(SegmentoDeTranscricao(
|
||||
intervalo.inicio + inicio - inicio_origem,
|
||||
intervalo.inicio + fim - inicio_origem,
|
||||
segmento.texto, segmento.confianca, palavras,
|
||||
segmento.emocao, segmento.confianca_emocao,
|
||||
segmento.caracteristicas_acusticas, segmento.falante,
|
||||
segmento.voz_aparente, segmento.confianca_voz))
|
||||
resultados.append(TranscricaoDoClipe(clipe.identificador, clipe.arquivo,
|
||||
intervalo.inicio, intervalo.fim, segmentos,
|
||||
(inicio_origem, fim_origem) if origem else None))
|
||||
return resultados
|
||||
|
||||
def _chave_do_arquivo(self, arquivo: str) -> str:
|
||||
caminho = Path(arquivo).expanduser().resolve()
|
||||
digest = hashlib.sha256()
|
||||
with caminho.open("rb") as conteudo:
|
||||
for bloco in iter(lambda: conteudo.read(1024 * 1024), b""):
|
||||
digest.update(bloco)
|
||||
return digest.hexdigest()
|
||||
|
||||
def _transcrever_arquivo(self, arquivo: str, chave: str) -> list[SegmentoDeTranscricao]:
|
||||
pasta = self.diretoria_de_trabalho / "arquivos" / chave
|
||||
nome_arquivo = Path(arquivo).stem
|
||||
modelo = self._nome_do_modelo()
|
||||
prefixo = f"transcricao-{self._nome_seguro(nome_arquivo)}-{self._nome_seguro(modelo)}"
|
||||
cache = pasta / f"{prefixo}.json"
|
||||
if cache.is_file():
|
||||
dados = json.loads(cache.read_text(encoding="utf-8"))
|
||||
if all("palavras" in item and
|
||||
(self.analisador_de_emocao is None or
|
||||
("emocao" in item and "voz_aparente" in item))
|
||||
for item in dados):
|
||||
return [self._segmento_do_json(item) for item in dados]
|
||||
partes = self.extrator.extrair(arquivo, pasta / "audio")
|
||||
segmentos: list[SegmentoDeTranscricao] = []
|
||||
for parte in partes:
|
||||
falas = self._diarizar(parte.caminho)
|
||||
for segmento in self.provider.transcrever(type("Audio", (), {"arquivo": str(parte.caminho)})()):
|
||||
analise = self._analisar_emocao(parte.caminho, segmento.inicio, segmento.fim)
|
||||
falante = self._falante_em(falas, segmento.inicio, segmento.fim)
|
||||
segmentos.append(SegmentoDeTranscricao(parte.inicio + segmento.inicio,
|
||||
parte.inicio + segmento.fim, segmento.texto, segmento.confianca,
|
||||
tuple(PalavraDeTranscricao(p.texto, parte.inicio + p.inicio,
|
||||
parte.inicio + p.fim, p.confianca,
|
||||
self._falante_em(falas, p.inicio, p.fim))
|
||||
for p in segmento.palavras),
|
||||
analise.get("emocao"), analise.get("confianca"),
|
||||
dict(analise.get("caracteristicas_acusticas", {})), falante,
|
||||
analise.get("voz_aparente"), analise.get("confianca_voz")))
|
||||
pasta.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text(json.dumps([asdict(item) for item in segmentos], ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
(pasta / f"{prefixo}.txt").write_text(
|
||||
" ".join(item.texto for item in segmentos if item.texto).strip() + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return segmentos
|
||||
|
||||
@staticmethod
|
||||
def _segmento_do_json(item: dict[str, Any]) -> SegmentoDeTranscricao:
|
||||
palavras = tuple(PalavraDeTranscricao(str(p["texto"]), float(p["inicio"]),
|
||||
float(p["fim"]), p.get("confianca"),
|
||||
p.get("falante"))
|
||||
for p in item.get("palavras", []))
|
||||
return SegmentoDeTranscricao(float(item["inicio"]), float(item["fim"]),
|
||||
str(item["texto"]), item.get("confianca"), palavras,
|
||||
item.get("emocao"), item.get("confianca_emocao"),
|
||||
dict(item.get("caracteristicas_acusticas", {})), item.get("falante"),
|
||||
item.get("voz_aparente"), item.get("confianca_voz"))
|
||||
|
||||
def _analisar_emocao(self, arquivo: Path, inicio: float, fim: float) -> dict[str, Any]:
|
||||
if self.analisador_de_emocao is None:
|
||||
return {}
|
||||
return dict(self.analisador_de_emocao.analisar(arquivo, inicio, fim))
|
||||
|
||||
def _diarizar(self, arquivo: Path) -> list[tuple[float, float, str]]:
|
||||
if self.diarizador is None:
|
||||
return []
|
||||
return list(self.diarizador.analisar(arquivo))
|
||||
|
||||
@staticmethod
|
||||
def _falante_em(falas: list[tuple[float, float, str]], inicio: float, fim: float) -> str | None:
|
||||
sobreposicoes = [(min(fim, saida) - max(inicio, entrada), falante)
|
||||
for entrada, saida, falante in falas
|
||||
if entrada < fim and saida > inicio]
|
||||
return max(sobreposicoes, default=(0.0, None))[1]
|
||||
|
||||
def _nome_do_modelo(self) -> str:
|
||||
"""Obtém o nome público do modelo sem acoplar o scanner ao provider."""
|
||||
modelo = getattr(self.provider, "nome_do_modelo", None)
|
||||
if modelo:
|
||||
return str(modelo)
|
||||
modelo = getattr(self.provider, "modelo", None)
|
||||
if modelo:
|
||||
return Path(str(modelo)).name
|
||||
return self.provider.__class__.__name__.lower()
|
||||
|
||||
@staticmethod
|
||||
def _nome_seguro(valor: str) -> str:
|
||||
return "".join(caractere if caractere.isalnum() or caractere in "._-" else "_"
|
||||
for caractere in valor).strip("._") or "arquivo"
|
||||
|
||||
@staticmethod
|
||||
def gerar_relatorio(resultados: list[TranscricaoDoClipe], destino: str | Path) -> Path:
|
||||
caminho = Path(destino)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from ..dominio import Clipe
|
||||
from ..integracoes.visual.contratos import (AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros,
|
||||
DetectorDeIntervalosDeCena, ExtratorDeQuadros)
|
||||
from ..integracoes.visual.modelos import QuadroDeVideo
|
||||
from .modelos import Cena, CenaVisual, EvidenciaVisual
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResultadoVisualDoClipe:
|
||||
identificador_do_clipe: str
|
||||
evidencias: list[EvidenciaVisual] = field(default_factory=list)
|
||||
cenas: list[Cena] = field(default_factory=list)
|
||||
cenas_visuais: list[CenaVisual] = field(default_factory=list)
|
||||
|
||||
|
||||
class DetectorDeCenas:
|
||||
"""Módulo profundo que orquestra frames, analisadores e cenas de um clipe.
|
||||
|
||||
A interface não expõe OpenCV, ONNX, MediaPipe, PySceneDetect ou Vision.
|
||||
Cada adapter pode ser trocado sem alterar chamadores nem resultados do domínio.
|
||||
"""
|
||||
|
||||
def __init__(self, extrator: ExtratorDeQuadros,
|
||||
analisadores_de_frame: list[AnalisadorDeFrame] | None = None,
|
||||
detectores_de_intervalo: list[DetectorDeIntervalosDeCena] | None = None,
|
||||
analisadores_de_sequencia: list[AnalisadorDeSequenciaDeQuadros] | None = None,
|
||||
ao_progresso: Callable[[int, int], None] | None = None) -> None:
|
||||
self.extrator = extrator
|
||||
self.analisadores_de_frame = analisadores_de_frame or []
|
||||
self.analisadores_de_sequencia = analisadores_de_sequencia or []
|
||||
self.detectores_de_intervalo = detectores_de_intervalo or []
|
||||
self.ao_progresso = ao_progresso
|
||||
|
||||
def detectar(self, clipe: Clipe) -> ResultadoVisualDoClipe:
|
||||
quadros = self.extrator.extrair(clipe)
|
||||
total = len(quadros) * len(self.analisadores_de_frame) + len(self.analisadores_de_sequencia)
|
||||
progresso = [0]
|
||||
evidencias = self._analisar_quadros(quadros, progresso, total) + self._analisar_sequencia(quadros, progresso, total)
|
||||
cenas = self._detectar_intervalos(clipe, quadros)
|
||||
cenas_visuais = [CenaVisual(
|
||||
clipe.identificador, cena.inicio, cena.fim,
|
||||
tuple(item for item in evidencias if item.inicio <= cena.fim and item.fim >= cena.inicio),
|
||||
cena.referencias,
|
||||
) for cena in cenas]
|
||||
return ResultadoVisualDoClipe(clipe.identificador, evidencias, cenas, cenas_visuais)
|
||||
|
||||
def _analisar_quadros(self, quadros: list[QuadroDeVideo], progresso: list[int] | None = None,
|
||||
total: int = 0) -> list[EvidenciaVisual]:
|
||||
resultado: list[EvidenciaVisual] = []
|
||||
for quadro in quadros:
|
||||
for analisador in self.analisadores_de_frame:
|
||||
resultado.extend(analisador.analisar(quadro))
|
||||
self._avancar_progresso(progresso, total)
|
||||
return resultado
|
||||
|
||||
def _analisar_sequencia(self, quadros: list[QuadroDeVideo], progresso: list[int] | None = None,
|
||||
total: int = 0) -> list[EvidenciaVisual]:
|
||||
resultado: list[EvidenciaVisual] = []
|
||||
for analisador in self.analisadores_de_sequencia:
|
||||
resultado.extend(analisador.analisar(quadros))
|
||||
self._avancar_progresso(progresso, total)
|
||||
return resultado
|
||||
|
||||
def _avancar_progresso(self, progresso: list[int] | None, total: int) -> None:
|
||||
if progresso is None or total <= 0:
|
||||
return
|
||||
progresso[0] += 1
|
||||
if self.ao_progresso:
|
||||
self.ao_progresso(progresso[0], total)
|
||||
|
||||
def _detectar_intervalos(self, clipe: Clipe, quadros: list[QuadroDeVideo]) -> list[Cena]:
|
||||
cenas: list[Cena] = []
|
||||
for detector in self.detectores_de_intervalo:
|
||||
cenas.extend(detector.detectar(clipe, quadros))
|
||||
cenas = self._consolidar_cenas(cenas)
|
||||
if not cenas and quadros:
|
||||
cenas = [Cena(quadros[0].timestamp, quadros[-1].timestamp)]
|
||||
return cenas
|
||||
|
||||
@staticmethod
|
||||
def _consolidar_cenas(cenas: list[Cena]) -> list[Cena]:
|
||||
resultado: list[Cena] = []
|
||||
for cena in sorted(cenas, key=lambda item: (item.inicio, item.fim)):
|
||||
if resultado and cena.inicio <= resultado[-1].fim:
|
||||
anterior = resultado[-1]
|
||||
resultado[-1] = Cena(anterior.inicio, max(anterior.fim, cena.fim),
|
||||
anterior.confianca or cena.confianca,
|
||||
anterior.referencias + cena.referencias)
|
||||
else:
|
||||
resultado.append(cena)
|
||||
return resultado
|
||||
|
||||
|
||||
class AnaliseVisualDaTimeline:
|
||||
"""Etapa do Scanner que guarda evidências e cenas por clipe no contexto."""
|
||||
|
||||
nome = "analise_visual_local"
|
||||
|
||||
def __init__(self, detector: DetectorDeCenas, continuar_em_falha: bool = True) -> None:
|
||||
self.detector = detector
|
||||
self.continuar_em_falha = continuar_em_falha
|
||||
|
||||
def executar(self, contexto):
|
||||
if contexto.timeline is None:
|
||||
return contexto
|
||||
for faixa in contexto.timeline.faixas:
|
||||
for clipe in faixa.clipes:
|
||||
if clipe.offline or not clipe.arquivo:
|
||||
continue
|
||||
try:
|
||||
resultado = self.detector.detectar(clipe)
|
||||
except Exception as exc:
|
||||
if not self.continuar_em_falha:
|
||||
raise
|
||||
contexto.avisos.append(
|
||||
f"analise_visual_indisponivel: clipe {clipe.identificador}: {exc}"
|
||||
)
|
||||
contexto.caracteristicas_visuais[clipe.identificador] = {
|
||||
"evidencias": [], "cenas": [], "cenas_visuais": [], "disponivel": False,
|
||||
}
|
||||
continue
|
||||
contexto.caracteristicas_visuais[clipe.identificador] = {
|
||||
"evidencias": resultado.evidencias,
|
||||
"cenas": resultado.cenas,
|
||||
"cenas_visuais": resultado.cenas_visuais,
|
||||
"disponivel": True,
|
||||
}
|
||||
contexto.cenas.extend(resultado.cenas)
|
||||
contexto.cenas_visuais.extend(resultado.cenas_visuais)
|
||||
contexto.cenas = DetectorDeCenas._consolidar_cenas(contexto.cenas)
|
||||
return contexto
|
||||
|
||||
|
||||
def criar_detector_visual_local(
|
||||
diretorio_de_cache: str | None = ".frames",
|
||||
intervalo_em_segundos: float = 1.0,
|
||||
) -> DetectorDeCenas:
|
||||
"""Monta o detector local padrão com uma interface curta para o Scanner."""
|
||||
from ..integracoes.visual import (
|
||||
AnalisadorDeComposicaoOpenCV,
|
||||
AnalisadorDeContinuidadeOpenCV,
|
||||
AnalisadorDeQualidadeOpenCV,
|
||||
ExtratorDeQuadrosOpenCV,
|
||||
)
|
||||
|
||||
return DetectorDeCenas(
|
||||
ExtratorDeQuadrosOpenCV(
|
||||
intervalo_em_segundos=intervalo_em_segundos,
|
||||
diretorio_de_cache=diretorio_de_cache,
|
||||
),
|
||||
analisadores_de_frame=[
|
||||
AnalisadorDeQualidadeOpenCV(),
|
||||
AnalisadorDeComposicaoOpenCV(),
|
||||
],
|
||||
analisadores_de_sequencia=[AnalisadorDeContinuidadeOpenCV()],
|
||||
)
|
||||
|
||||
|
||||
def criar_detector_apple_vision(
|
||||
diretorio_de_cache: str | None = ".frames",
|
||||
intervalo_em_segundos: float = 1.0,
|
||||
configuracao=None,
|
||||
) -> DetectorDeCenas:
|
||||
"""Monta a análise local de cena baseada em Vision e arquivos de origem."""
|
||||
from .configuracao_visual import ConfiguracaoVisualDoScanner
|
||||
from ..integracoes.visual import (AnalisadorAppleVisionNativo,
|
||||
AnalisadorSequenciaAppleVisionNativo,
|
||||
ExtratorDeQuadrosFFmpeg)
|
||||
|
||||
perfil = configuracao or ConfiguracaoVisualDoScanner(intervalo_em_segundos=intervalo_em_segundos)
|
||||
|
||||
return DetectorDeCenas(
|
||||
ExtratorDeQuadrosFFmpeg(intervalo_em_segundos=perfil.intervalo_em_segundos,
|
||||
diretorio_de_cache=diretorio_de_cache or ".frames"),
|
||||
analisadores_de_frame=[AnalisadorAppleVisionNativo(
|
||||
recursos=tuple(sorted(perfil.recursos_vision)),
|
||||
interpretar_com_apple_intelligence=perfil.interpretar_cena,
|
||||
)],
|
||||
analisadores_de_sequencia=[AnalisadorSequenciaAppleVisionNativo()]
|
||||
if perfil.analisar_continuidade else [],
|
||||
)
|
||||
Reference in New Issue
Block a user