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:
@@ -364,6 +364,18 @@ O modelo utilizado deverá ficar no módulo `providers/transcricao/`.
|
||||
|
||||
# 6. Submódulo `visual`
|
||||
|
||||
## Regra de acesso ao Premiere
|
||||
|
||||
Sempre que o Scanner precisar acessar a timeline, a sequência ativa, as faixas
|
||||
ou os clipes, deve utilizar `AcessoAoEditor` em
|
||||
`engine.integracoes.premiere.leitura`. Essa classe já encapsula o cliente MCP e
|
||||
é a única porta de leitura do Premiere para o Engine.
|
||||
|
||||
O submódulo `visual` recebe uma `Timeline` de domínio pelo
|
||||
`ContextoDeAnalise`; seus adapters não devem chamar o MCP, o CEP ou o UXP
|
||||
diretamente. Em testes, injete uma timeline pronta ou um fake de
|
||||
`AcessoAoEditor` na etapa de descoberta.
|
||||
|
||||
## `ExtracaoDeQuadros`
|
||||
|
||||
### Papel
|
||||
|
||||
@@ -26,6 +26,7 @@ Cada módulo principal deverá possuir um documento próprio nesta pasta.
|
||||
arquitetura/
|
||||
├── README.md
|
||||
├── scanner.md
|
||||
├── biblioteca-inteligente-de-videos.md
|
||||
├── integracao-com-premiere.md
|
||||
├── leitura-da-timeline-via-mcp.md
|
||||
├── plano-primeira-etapa-scanner-e-mcp.md
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
# Biblioteca Inteligente de Vídeos
|
||||
|
||||
## Objetivo
|
||||
|
||||
Transformar uma pasta selecionada pelo usuário em uma biblioteca audiovisual documentada e pesquisável. O sistema deve responder não apenas quais arquivos existem, mas quais evidências de conteúdo aparecem em cada arquivo e em que intervalo temporal.
|
||||
|
||||
Exemplos de consultas:
|
||||
|
||||
- “Encontre vídeos em que uma pessoa caminha na rua.”
|
||||
- “Mostre cenas em que alguém fala diante de uma câmera.”
|
||||
- “Encontre momentos em que aparece um carro vermelho.”
|
||||
|
||||
O módulo é de descoberta, análise e recuperação. Ele não decide cortes, não altera a timeline e não deve ser confundido com o índice de Media Intelligence do Premiere. Será um índice local próprio, alimentado pelos adapters disponíveis no projeto.
|
||||
|
||||
## Decisões arquiteturais
|
||||
|
||||
### 1. Nova unidade de domínio: biblioteca, ativo e evidência
|
||||
|
||||
O scanner atual é orientado a uma `Timeline`; este requisito é orientado a uma `BibliotecaDeVideos`. Os dois fluxos podem compartilhar adapters de mídia, mas não devem compartilhar o mesmo contexto de execução.
|
||||
|
||||
```text
|
||||
BibliotecaDeVideos
|
||||
└── AtivoDeVideo
|
||||
├── MetadadosDoArquivo
|
||||
├── SegmentoDeConteudo
|
||||
│ ├── EvidenciaVisual
|
||||
│ ├── SegmentoDeTranscricao
|
||||
│ └── EvidenciaDeAudio
|
||||
└── RepresentacoesDeBusca
|
||||
```
|
||||
|
||||
Um `AtivoDeVideo` é identificado por uma identidade estável do conteúdo, não apenas pelo nome do arquivo. A identidade inicial deve combinar caminho normalizado, tamanho, data de modificação e uma impressão digital do arquivo. Quando houver colisão ou suspeita de alteração, o hash completo deve confirmar a identidade.
|
||||
|
||||
Uma `Evidencia` é um fato observado por um provider, com tipo, valor, intervalo no arquivo de origem, confiança, provider e versão do modelo. Descrições geradas por IA são evidências com proveniência; não são fatos absolutos nem instruções executáveis.
|
||||
|
||||
### 2. O módulo externo deve ser profundo
|
||||
|
||||
O chamador não deve conhecer FFmpeg, OpenCV, Vision, Whisper, filas, SQLite ou o mecanismo vetorial. A seam pública deve oferecer poucas operações orientadas a intenção:
|
||||
|
||||
```python
|
||||
class BibliotecaDeVideos:
|
||||
def indexar_pasta(self, pasta: str | Path, configuracao: ConfiguracaoDaBiblioteca) -> IdDaExecucao: ...
|
||||
def obter_status(self, execucao: IdDaExecucao) -> StatusDaIndexacao: ...
|
||||
def buscar(self, consulta: str, opcoes: OpcoesDeBusca | None = None) -> list[ResultadoDeBusca]: ...
|
||||
def obter_ativo(self, identificador: str) -> AtivoDeVideo | None: ...
|
||||
```
|
||||
|
||||
`indexar_pasta` inicia ou agenda uma execução idempotente e retorna imediatamente. O progresso é consultado pelo identificador da execução. `buscar` retorna resultados agrupados por ativo, com trechos temporais e evidências que justificam cada resultado.
|
||||
|
||||
O módulo esconde a coordenação entre descoberta incremental, análise, persistência e indexação de busca. Adapters internos podem variar sem alterar essa interface.
|
||||
|
||||
### 3. Indexação incremental é regra do domínio
|
||||
|
||||
Cada ativo deve registrar:
|
||||
|
||||
- impressão digital observada;
|
||||
- versão do contrato de análise;
|
||||
- versão de cada provider/modelo usado;
|
||||
- etapas concluídas;
|
||||
- etapas com erro ou aviso;
|
||||
- último status e última execução.
|
||||
|
||||
Um arquivo inalterado e já concluído não deve ser analisado novamente. Um arquivo novo entra na fila. Um arquivo cuja impressão digital mudou invalida apenas as etapas derivadas daquele conteúdo. Se somente um provider ou sua versão mudou, a política pode invalidar apenas a etapa correspondente.
|
||||
|
||||
Arquivos removidos da pasta não devem ser apagados imediatamente do índice: passam a `ausente_na_origem`, preservando resultados históricos e permitindo recuperação caso retornem. A remoção definitiva deve ser uma operação explícita futura.
|
||||
|
||||
## Módulos e responsabilidades
|
||||
|
||||
### `biblioteca`
|
||||
|
||||
Módulo profundo e seam principal. Recebe a intenção do usuário, cria uma execução, delega o trabalho ao coordenador e expõe status, resultados e erros normalizados.
|
||||
|
||||
Não conhece detalhes de providers nem executa chamadas de sistema diretamente.
|
||||
|
||||
### `descoberta_da_pasta`
|
||||
|
||||
Percorre a pasta autorizada, respeitando configuração de recursão, extensões suportadas, exclusões e links simbólicos. Produz candidatos de arquivos de vídeo e reconcilia o snapshot atual com o último snapshot persistido.
|
||||
|
||||
Não extrai metadados, não abre frames e não chama IA.
|
||||
|
||||
### `identidade_do_ativo`
|
||||
|
||||
Calcula e compara a impressão digital do arquivo. Encapsula a política de “novo”, “inalterado”, “alterado” e “ausente”. Deve ser determinística e testável com um filesystem falso.
|
||||
|
||||
### `coordenacao_da_indexacao`
|
||||
|
||||
Transforma candidatos em trabalhos por ativo e etapa, respeita dependências, atualiza progresso, trata cancelamento, permite retomada e aplica a política de erro por ativo.
|
||||
|
||||
Dependências sugeridas:
|
||||
|
||||
```text
|
||||
descoberta
|
||||
└── metadados
|
||||
├── amostragem_de_frames ── análise_visual ── segmentos_visuais
|
||||
└── extração_de_audio ── transcrição ── segmentos_de_fala
|
||||
└── análise_de_audio
|
||||
|
||||
segmentos + evidências ── consolidação_temporal ── indexação_de_busca
|
||||
```
|
||||
|
||||
Metadados devem ser pré-requisito para calcular duração e amostragem. O ramo visual e o ramo de áudio podem executar em paralelo. A consolidação e a indexação só ocorrem depois dos ramos habilitados, sem exigir que todos tenham sucesso.
|
||||
|
||||
### `analise_de_conteudo_audiovisual`
|
||||
|
||||
Orquestra os adapters já existentes no projeto:
|
||||
|
||||
- `ExtracaoDeMetadados` para dados técnicos;
|
||||
- `ExtratorDeQuadros` e `DetectorDeCenas` para amostragem e intervalos;
|
||||
- analisadores OpenCV, Apple Vision, ONNX ou MediaPipe para evidências visuais;
|
||||
- adapters Whisper, Apple Speech ou Groq para transcrição;
|
||||
- diarização e emoção local quando habilitadas.
|
||||
|
||||
O resultado deve usar modelos do domínio, especialmente `EvidenciaVisual`, `CenaVisual` e `SegmentoDeTranscricao`. Um provider indisponível gera aviso de capacidade e não deve apagar evidências produzidas por outros providers.
|
||||
|
||||
O módulo não deve pedir que um modelo de linguagem “assista” ao arquivo inteiro. A análise deve trabalhar com amostras temporais, cenas e agregação de evidências. Uma descrição de cena pode ser criada a partir de um conjunto limitado de frames, sempre mantendo os timestamps que a sustentam.
|
||||
|
||||
### `consolidacao_temporal`
|
||||
|
||||
Converte observações pontuais em intervalos úteis para busca. Observações do mesmo tipo e valor semelhante podem ser agrupadas quando estão próximas, com uma margem configurável. O resultado precisa conservar as observações originais, pois elas são a evidência auditável do intervalo consolidado.
|
||||
|
||||
Esse módulo é o lugar correto para responder “em que momento” e para evitar que a busca retorne apenas o arquivo inteiro.
|
||||
|
||||
### `persistencia_do_indice`
|
||||
|
||||
Adapter responsável por salvar e ler o estado durável. A primeira implementação deve ser local e transacional, preferencialmente SQLite, com arquivos de frames/áudio tratados como cache reconstruível fora do banco.
|
||||
|
||||
Interface mínima:
|
||||
|
||||
```python
|
||||
class RepositorioDaBiblioteca(Protocol):
|
||||
def reconciliar_snapshot(self, snapshot: SnapshotDaPasta) -> ResultadoDaReconciliacao: ...
|
||||
def salvar_resultado(self, resultado: ResultadoDoAtivo) -> None: ...
|
||||
def obter_trabalho_pendente(self, limite: int) -> list[TrabalhoDeIndexacao]: ...
|
||||
def atualizar_status(self, status: StatusDaIndexacao) -> None: ...
|
||||
def buscar_evidencias(self, consulta: ConsultaNormalizada) -> list[ResultadoDeBusca]: ...
|
||||
```
|
||||
|
||||
O contrato deve permitir um adapter em memória para testes. Nenhum provider deve escrever diretamente no banco.
|
||||
|
||||
### `busca_semantica`
|
||||
|
||||
Recebe texto livre, normaliza a consulta e combina três fontes:
|
||||
|
||||
1. busca lexical em nomes, transcrições, rótulos e descrições;
|
||||
2. busca vetorial em descrições de cenas, transcrições e evidências;
|
||||
3. filtros estruturados por ativo, intervalo, tipo de evidência, confiança e status.
|
||||
|
||||
O MVP deve priorizar busca híbrida com ranking explicável. Cada resultado deve informar score, trecho, arquivo e evidências correspondentes. Busca vetorial sem evidência temporal não atende ao requisito.
|
||||
|
||||
Um adapter vetorial pode ser adicionado depois. A persistência deve permitir começar com SQLite FTS e embeddings opcionais, sem acoplar o domínio a um banco vetorial específico.
|
||||
|
||||
### `integracao_com_timeline_e_editor`
|
||||
|
||||
Traduz um `ResultadoDeBusca` para ações de revisão: abrir o arquivo, posicionar o playhead, criar marcador ou propor um clipe para o fluxo editorial. A integração deve ser somente leitura/proposição na primeira versão.
|
||||
|
||||
Não deve alterar a timeline automaticamente nem tratar um resultado sem revisão como autorização de edição.
|
||||
|
||||
## Modelo de dados lógico
|
||||
|
||||
```text
|
||||
bibliotecas
|
||||
id, raiz, configuracao_json, criada_em, atualizada_em
|
||||
|
||||
ativos
|
||||
id, biblioteca_id, caminho, nome, extensao, status_origem,
|
||||
tamanho, modificado_em, fingerprint, criado_em, atualizado_em
|
||||
|
||||
metadados_dos_ativos
|
||||
ativo_id, duracao, largura, altura, fps, codecs, formato, json_extra
|
||||
|
||||
execucoes_de_indexacao
|
||||
id, biblioteca_id, status, motivo, iniciada_em, finalizada_em,
|
||||
total_trabalhos, concluidos, falhos, cancelada_em
|
||||
|
||||
trabalhos_de_indexacao
|
||||
id, execucao_id, ativo_id, etapa, versao, status, tentativas,
|
||||
erro, iniciada_em, finalizada_em
|
||||
|
||||
segmentos_de_conteudo
|
||||
id, ativo_id, inicio, fim, tipo, resumo, confianca, origem
|
||||
|
||||
evidencias
|
||||
id, segmento_id, tipo, valor_json, inicio, fim, confianca,
|
||||
provider, modelo, versao
|
||||
|
||||
transcricoes
|
||||
id, ativo_id, inicio, fim, texto, falante, confianca, provider, versao
|
||||
|
||||
representacoes_de_busca
|
||||
id, alvo_tipo, alvo_id, texto, embedding, indice_lexical
|
||||
```
|
||||
|
||||
Os intervalos são sempre relativos ao arquivo de origem. Quando houver uso numa timeline, a tradução para o intervalo da timeline pertence à integração com o editor, usando o `intervalo_na_origem` do domínio existente.
|
||||
|
||||
## Fluxo completo
|
||||
|
||||
```text
|
||||
Usuário seleciona pasta
|
||||
↓
|
||||
BibliotecaDeVideos.indexar_pasta()
|
||||
↓
|
||||
Execução persistida + trabalhos pendentes
|
||||
↓
|
||||
Snapshot da pasta e reconciliação por fingerprint
|
||||
↓
|
||||
Metadados por ativo novo/alterado
|
||||
↓
|
||||
Ramos visual e áudio em segundo plano
|
||||
↓
|
||||
Consolidação de cenas, evidências e falas
|
||||
↓
|
||||
Atualização transacional do índice lexical/vetorial
|
||||
↓
|
||||
Status consultável e busca com timestamps
|
||||
```
|
||||
|
||||
Cada trabalho deve ser retomável. A gravação de uma etapa deve ser atômica: o índice não pode anunciar uma etapa concluída antes de seus dados e sua versão estarem persistidos.
|
||||
|
||||
## Contrato de status e falhas
|
||||
|
||||
Status da biblioteca: `nao_indexada`, `indexando`, `parcial`, `concluida`, `falhou` ou `ausente_na_origem`.
|
||||
|
||||
Status da etapa: `pendente`, `em_execucao`, `concluida`, `concluida_com_avisos`, `falhou`, `cancelada`.
|
||||
|
||||
Falha de um arquivo não deve interromper toda a biblioteca. Falha de descoberta ou persistência deve interromper a execução, pois torna o resultado inconsistente. Falha de um provider deve marcar a capacidade correspondente como indisponível e preservar as demais etapas.
|
||||
|
||||
O status deve conter progresso por contagem de trabalhos e por ativo; percentual baseado apenas em duração de vídeo pode ficar enganoso quando há arquivos muito diferentes.
|
||||
|
||||
## Escopo recomendado do MVP
|
||||
|
||||
1. Seleção e persistência de uma biblioteca local.
|
||||
2. Descoberta recursiva de extensões configuradas.
|
||||
3. Fingerprint e reconciliação incremental.
|
||||
4. Metadados via FFprobe.
|
||||
5. Amostragem de frames e análise visual já disponível localmente.
|
||||
6. Transcrição por um adapter configurado, com timestamps.
|
||||
7. Segmentos temporais e evidências persistidos em SQLite.
|
||||
8. Busca lexical por nome, transcrição, rótulos e descrições normalizadas.
|
||||
9. Status, retomada e cancelamento do processamento.
|
||||
10. Resultado com caminho, intervalo, confiança e evidência.
|
||||
|
||||
Ficam para uma segunda etapa: embeddings, ranking híbrido, diarização avançada, reconhecimento de identidade, monitoramento contínuo por filesystem watcher, agrupamento de takes semelhantes e criação automática de marcadores no Premiere.
|
||||
|
||||
## Integração com o que já existe
|
||||
|
||||
O novo módulo deve reutilizar os adapters de `code/engine/integracoes/midia`, `code/engine/integracoes/visual` e `code/engine/integracoes/whisper`. A implementação atual de `scanner` pode continuar atendendo análises de timeline; o novo coordenador transforma `AtivoDeVideo` em uma entrada compatível com os adapters, sem fazer o domínio da biblioteca depender de `Timeline`.
|
||||
|
||||
O `DescobertaDeArquivos` existente contém parte da política de validação local e pode fornecer um adapter compartilhado, desde que sua interface não passe a conhecer biblioteca, fila ou persistência. A análise visual local já produz evidências temporais adequadas, mas a etapa de consolidação deve ficar fora do detector de cenas para manter a separação entre observação e indexação.
|
||||
|
||||
As limitações documentadas em `advanced-feature-support.ts` permanecem válidas: o sistema não deve alegar acesso ao índice nativo do Premiere nem iniciar/monitorar operações não expostas por API pública. A biblioteca local é uma capacidade independente.
|
||||
|
||||
## Testabilidade e seams
|
||||
|
||||
O domínio deve ser testável sem FFmpeg, GPU, macOS Vision, rede ou arquivos reais. Adapters necessários para testes:
|
||||
|
||||
- filesystem que retorna snapshots controlados;
|
||||
- calculador de fingerprint determinístico;
|
||||
- provider de metadados falso;
|
||||
- extrator de frames falso;
|
||||
- providers visual e de transcrição falsos;
|
||||
- relógio injetável;
|
||||
- repositório em memória;
|
||||
- fila síncrona ou executor controlado;
|
||||
- ranking lexical/vetorial falso.
|
||||
|
||||
Testes prioritários:
|
||||
|
||||
- arquivo inalterado não gera trabalho novamente;
|
||||
- novo arquivo gera apenas as etapas necessárias;
|
||||
- arquivo alterado invalida resultados derivados;
|
||||
- mudança de versão de provider invalida somente sua etapa;
|
||||
- falha visual não apaga transcrição;
|
||||
- timestamps nunca ultrapassam a duração conhecida;
|
||||
- resultados de busca preservam evidências e intervalo;
|
||||
- retomada continua do último trabalho persistido;
|
||||
- cancelamento não deixa etapa marcada como concluída;
|
||||
- arquivo removido vira ausente sem perder histórico.
|
||||
|
||||
## Decisões em aberto
|
||||
|
||||
Antes da implementação, ainda precisamos escolher:
|
||||
|
||||
1. banco local definitivo: SQLite puro, SQLite com FTS5 e embeddings em arquivo, ou outro adapter;
|
||||
2. executor em segundo plano: processo Python separado, thread controlada ou integração com o host;
|
||||
3. provider visual padrão do MVP: OpenCV, Apple Vision ou configuração por perfil;
|
||||
4. provider de transcrição padrão e política de privacidade para enviar áudio a serviços externos;
|
||||
5. extensões, exclusões e limite de profundidade da pasta;
|
||||
6. política de retenção do cache de frames e áudio;
|
||||
7. formato do contrato de descrição semântica produzido pelo modelo de IA.
|
||||
|
||||
Essas escolhas não devem alterar a interface de `BibliotecaDeVideos`; devem apenas selecionar adapters e configuração.
|
||||
|
||||
## Catálogo SQLite e processamento contínuo
|
||||
|
||||
### SQLite como catálogo, não como depósito de mídia
|
||||
|
||||
SQLite é adequado para o catálogo local porque oferece transações, consultas relacionais, FTS5 para busca textual e baixo custo operacional. O banco não deve armazenar vídeos, áudio extraído ou imagens em escala. Esses dados ficam na pasta original ou em um cache controlado; no banco ficam referências, metadados, resultados compactos e estado de processamento.
|
||||
|
||||
O arquivo do catálogo deve ficar fora da pasta indexada, em um diretório de dados do aplicativo. Assim, uma pasta pode ser removida, movida ou compartilhada sem levar o estado interno do agente junto com ela. A configuração deve permitir uma biblioteca por pasta e várias bibliotecas no mesmo catálogo.
|
||||
|
||||
Para lidar com nomes repetidos, a chave do ativo não será `nome`. O cadastro deve usar um identificador interno e único, por exemplo:
|
||||
|
||||
```text
|
||||
identificador_do_ativo = UUID interno
|
||||
identidade_do_conteudo = hash do conteúdo + tamanho
|
||||
localizacao = biblioteca + caminho relativo normalizado
|
||||
```
|
||||
|
||||
O caminho relativo distingue duas cópias iguais em locais diferentes; a identidade do conteúdo permite reconhecer um arquivo renomeado ou movido dentro da mesma biblioteca. O cálculo deve ser progressivo: primeiro comparar tamanho e data de modificação, depois calcular uma impressão digital parcial; o hash completo fica reservado para arquivos suspeitos, duplicatas ou confirmação de identidade.
|
||||
|
||||
### Estado persistido por etapa
|
||||
|
||||
O catálogo deve tratar a análise como um conjunto de trabalhos independentes, não como uma operação monolítica por vídeo. Cada trabalho tem `ativo_id`, `etapa`, `versao_da_etapa`, `status`, `progresso`, `checkpoint`, `tentativas`, `erro` e timestamps.
|
||||
|
||||
Checkpoints possíveis:
|
||||
|
||||
- último timestamp de frame processado;
|
||||
- último intervalo de áudio transcrito;
|
||||
- identificador da janela de cena atual;
|
||||
- lote de evidências já persistido;
|
||||
- versão do modelo e parâmetros efetivos.
|
||||
|
||||
Um checkpoint só é confirmado junto com os resultados daquele lote. Em caso de interrupção, o executor retoma a partir do último checkpoint confirmado, com uma pequena sobreposição temporal para não perder eventos na transição entre lotes. A escrita deve ser idempotente usando uma chave lógica como `ativo + etapa + versão + intervalo + provider`.
|
||||
|
||||
### Worker de baixa prioridade
|
||||
|
||||
O processamento contínuo deve ser implementado como um worker controlado pelo sistema, com uma iteração curta e cooperativa. Ele não deve manter vídeos, frames ou áudios inteiros em memória.
|
||||
|
||||
Política inicial:
|
||||
|
||||
- um ativo por vez por padrão;
|
||||
- um lote pequeno de frames por vez;
|
||||
- áudio extraído em arquivo temporário ou stream, nunca inteiro em memória;
|
||||
- transação SQLite curta por lote;
|
||||
- pausa entre lotes quando a máquina estiver ocupada;
|
||||
- suspensão com bateria, modo de economia de energia, temperatura alta ou pressão de memória;
|
||||
- limite configurável de CPU, memória, espaço de cache e tempo por ciclo;
|
||||
- cancelamento cooperativo verificado entre frames, lotes e etapas;
|
||||
- processos de provider isolados quando uma biblioteca nativa puder bloquear ou consumir memória excessiva.
|
||||
|
||||
O worker deve consultar uma política de recursos antes de iniciar cada lote:
|
||||
|
||||
```python
|
||||
class PoliticaDeRecursos(Protocol):
|
||||
def pode_executar(self, trabalho: TrabalhoDeIndexacao) -> bool: ...
|
||||
def tamanho_do_lote(self, trabalho: TrabalhoDeIndexacao) -> int: ...
|
||||
def deve_pausar(self) -> bool: ...
|
||||
```
|
||||
|
||||
O objetivo não é manter o agente analisando a qualquer custo, mas aproveitar períodos ociosos. Se a máquina estiver ocupada, o worker deve dormir e deixar o sistema responsivo. A prioridade do processo e a afinidade de CPU são detalhes de um adapter do host, não regras espalhadas pelo domínio.
|
||||
|
||||
### Varredura contínua
|
||||
|
||||
O modo contínuo deve combinar duas estratégias:
|
||||
|
||||
1. uma varredura periódica e lenta da pasta, que é a fonte de verdade;
|
||||
2. um watcher opcional para antecipar a descoberta de arquivos novos ou alterados.
|
||||
|
||||
O watcher não deve iniciar análise diretamente. Ele apenas marca a biblioteca como “precisa reconciliar”; a próxima varredura confirma o estado, evitando arquivos ainda sendo copiados. Um arquivo só entra na fila depois de permanecer estável por um intervalo configurável e passar por uma leitura mínima de metadados.
|
||||
|
||||
Ao iniciar, retomar ou acordar após ocioso, o worker deve:
|
||||
|
||||
```text
|
||||
reconciliar pasta → atualizar origem dos ativos → recalcular trabalhos necessários
|
||||
→ escolher próximo trabalho → processar lote → persistir checkpoint → repetir
|
||||
```
|
||||
|
||||
### Frequência de frames
|
||||
|
||||
Não existe uma frequência única ideal. Analisar todos os frames é caro e, para busca semântica, geralmente redundante. A recomendação para o MVP é uma amostragem em camadas:
|
||||
|
||||
| Camada | Frequência inicial | Finalidade |
|
||||
|---|---:|---|
|
||||
| triagem | 1 frame a cada 2–5 s | descobrir duração, atividade e mudanças grosseiras |
|
||||
| cena ativa | 1–2 frames/s | descrever objetos, pessoas e composição |
|
||||
| transição | frequência temporariamente maior | refinar início/fim de uma mudança de cena |
|
||||
| áudio | janelas contínuas | transcrição e detecção de fala/silêncio |
|
||||
|
||||
O valor de `1 frame/s` é um bom ponto de partida para vídeos comuns, mas deve ser uma configuração, não uma regra fixa. Conteúdo com movimento rápido, cortes frequentes, texto na tela ou ações curtas precisa de amostragem adaptativa. Conteúdo estático pode reduzir a frequência quando os frames consecutivos têm baixa diferença visual.
|
||||
|
||||
O detector deve aumentar a frequência localmente quando detectar mudança de cena, movimento relevante, fala, texto novo ou baixa confiança. Deve reduzir a frequência quando houver continuidade visual, silêncio prolongado ou repetição de frames. Cada evidência precisa registrar a amostragem usada, para que a ausência de detecção não seja interpretada como prova de ausência.
|
||||
|
||||
### Transcrição
|
||||
|
||||
O adapter local baseado em `faster-whisper` é a escolha padrão recomendada para o catálogo: mantém o processamento privado, entrega timestamps e já se encaixa nos providers existentes. O tamanho do modelo deve ser configurável por perfil de recurso:
|
||||
|
||||
- perfil econômico: modelo menor, CPU e baixa prioridade;
|
||||
- perfil equilibrado: modelo intermediário, possivelmente aceleração disponível;
|
||||
- perfil qualidade: modelo maior, somente quando solicitado ou quando houver tempo ocioso.
|
||||
|
||||
Apple Speech pode ser um adapter preferencial em macOS quando o requisito for baixo consumo e o idioma suportado for suficiente. Groq ou outro provider remoto deve ser opt-in, com consentimento explícito, indicação de que o áudio sai da máquina e registro do provider usado. A transcrição persistida deve guardar idioma, modelo, provider, confiança e timestamps; trocar o modelo invalida somente a etapa de transcrição, não os metadados nem necessariamente a análise visual.
|
||||
|
||||
### Combinação de fontes
|
||||
|
||||
O cadastro útil para o agente deve manter as fontes separadas e criar uma representação consolidada:
|
||||
|
||||
```text
|
||||
metadados técnicos
|
||||
+ transcrição temporal
|
||||
+ evidências visuais temporais
|
||||
+ evidências de áudio
|
||||
+ mudanças de cena
|
||||
↓
|
||||
segmento de conteúdo
|
||||
↓
|
||||
texto indexável + filtros + evidências
|
||||
```
|
||||
|
||||
Um segmento pode ter o resumo “pessoa entra em uma sala enquanto fala”, mas deve apontar para: o intervalo temporal, os frames que sustentam “pessoa” e “entra”, e o trecho de transcrição que sustenta “fala”. O resumo serve para recuperação; as evidências servem para auditoria, ranking e revisão humana.
|
||||
|
||||
### Estratégia de prioridade
|
||||
|
||||
A prioridade deve ser calculada no momento de escolher o próximo trabalho, sem alterar a identidade nem o resultado do ativo. Uma pontuação inicial pode considerar:
|
||||
|
||||
```text
|
||||
prioridade = intenção explícita
|
||||
+ ativo aberto ou usado recentemente no editor
|
||||
+ arquivo novo ou alterado
|
||||
+ etapa necessária para uma busca pendente
|
||||
+ tamanho/tempo estimado que permita concluir um lote
|
||||
- custo estimado
|
||||
- idade da última tentativa com falha
|
||||
```
|
||||
|
||||
Categorias práticas:
|
||||
|
||||
1. urgente: ativo solicitado numa busca ou aberto para edição;
|
||||
2. alta: arquivo novo, alterado ou associado ao projeto atual;
|
||||
3. normal: arquivos ainda não indexados;
|
||||
4. baixa: reprocessamento por melhoria de modelo ou análise opcional.
|
||||
|
||||
Para evitar starvation, trabalhos de baixa prioridade recebem um aumento gradual de prioridade conforme envelhecem. Um arquivo grande não deve bloquear a fila inteira: o scheduler o divide em lotes e alterna com trabalhos menores.
|
||||
|
||||
## Configuração operacional proposta
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class ConfiguracaoDaBiblioteca:
|
||||
extensoes: tuple[str, ...] = (".mp4", ".mov", ".mxf", ".mkv", ".avi")
|
||||
recursiva: bool = True
|
||||
intervalo_da_varredura_em_segundos: int = 900
|
||||
estabilidade_do_arquivo_em_segundos: int = 30
|
||||
frequencia_de_triagem_em_fps: float = 0.25
|
||||
frequencia_de_cena_em_fps: float = 1.0
|
||||
tamanho_do_lote_de_frames: int = 32
|
||||
concorrencia: int = 1
|
||||
limite_de_cache_em_gb: float = 10.0
|
||||
permitir_provider_remoto: bool = False
|
||||
perfil_de_recursos: str = "economico"
|
||||
```
|
||||
|
||||
Os defaults favorecem responsividade e privacidade. A configuração não deve expor detalhes de cada biblioteca de visão; deve selecionar perfis e permitir ajustes apenas onde houver evidência de necessidade.
|
||||
|
||||
## Fases de evolução
|
||||
|
||||
### Fase 1 — catálogo confiável
|
||||
|
||||
SQLite, descoberta periódica, identidade por fingerprint, metadados, fila persistida, checkpoints, análise visual em baixa frequência, transcrição local e busca textual temporal.
|
||||
|
||||
### Fase 2 — recuperação semântica
|
||||
|
||||
Embeddings para segmentos e transcrições, busca híbrida, reranking e consultas por conceitos não presentes literalmente no texto. O embedding deve ser versionado e reconstruível; não deve ser a única representação do conhecimento.
|
||||
|
||||
### Fase 3 — integração editorial
|
||||
|
||||
Busca a partir do contexto da timeline, abertura no trecho encontrado, marcadores de revisão e propostas de stringout. Qualquer mutação na timeline continua exigindo uma etapa explícita de revisão e autorização.
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Entrada local do painel CEP para uma execução configurada do Scanner."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import re
|
||||
|
||||
from engine.integracoes.premiere.conversores import ConversorDeTimeline
|
||||
from engine.scanner import ConfiguracaoVisualDoScanner, criar_detector_apple_vision, gerar_relatorio_visual
|
||||
from engine.scanner.transcricao_da_timeline import TranscricaoDaTimeline
|
||||
|
||||
|
||||
MODELO_DIARIZACAO_PADRAO = "pyannote/speaker-diarization-community-1"
|
||||
|
||||
|
||||
def nome_seguro(nome: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9._-]+", "-", nome.strip()).strip(".-") or "timeline"
|
||||
|
||||
|
||||
def executar(entrada: Path, saida: Path) -> Path:
|
||||
pedido = json.loads(entrada.read_text(encoding="utf-8"))
|
||||
configuracao = ConfiguracaoVisualDoScanner.de_dict(pedido["perfil"])
|
||||
timeline = ConversorDeTimeline().converter(pedido["timeline"])
|
||||
clipes = [clipe for faixa in timeline.faixas if faixa.tipo == "video"
|
||||
and faixa.indice in configuracao.faixas_de_video for clipe in faixa.clipes]
|
||||
total_estimado = sum(max(1, math.ceil((clipe.intervalo_na_origem or clipe.intervalo_na_timeline).duracao /
|
||||
configuracao.intervalo_em_segundos)) + 1 for clipe in clipes) or 1
|
||||
concluidos = 0
|
||||
|
||||
def emitir(etapa: str, percentual: float, clipe: str = "") -> None:
|
||||
print(json.dumps({"evento": "progresso", "etapa": etapa, "percentual": round(percentual, 1),
|
||||
"clipe": clipe}, ensure_ascii=False), flush=True)
|
||||
|
||||
resultados = []
|
||||
for clipe in clipes:
|
||||
peso = max(1, math.ceil((clipe.intervalo_na_origem or clipe.intervalo_na_timeline).duracao /
|
||||
configuracao.intervalo_em_segundos)) + 1
|
||||
inicio_do_clipe = concluidos
|
||||
|
||||
def progresso_atual(atual: int, total: int, nome: str = clipe.nome,
|
||||
base: int = inicio_do_clipe, peso_do_clipe: int = peso) -> None:
|
||||
emitir("Analisando frames", 100 * (base + peso_do_clipe * atual / max(total, 1)) / total_estimado, nome)
|
||||
|
||||
detector = criar_detector_apple_vision(Path(tempfile.gettempdir()) / "premiere-mcp-scanner",
|
||||
configuracao=configuracao)
|
||||
detector.ao_progresso = progresso_atual
|
||||
emitir("Extraindo frames", 100 * inicio_do_clipe / total_estimado, clipe.nome)
|
||||
try:
|
||||
resultado = detector.detectar(clipe)
|
||||
arquivo = Path(tempfile.gettempdir()) / f"scanner-{clipe.identificador}.json"
|
||||
gerar_relatorio_visual(clipe, resultado, arquivo, configuracao.intervalo_em_segundos)
|
||||
resultados.append(json.loads(arquivo.read_text(encoding="utf-8")))
|
||||
except Exception as erro:
|
||||
resultados.append({"clipe": {"identificador": clipe.identificador, "nome": clipe.nome,
|
||||
"arquivo_original": clipe.arquivo}, "erro": str(erro)})
|
||||
concluidos += peso
|
||||
faixas_de_audio = [faixa for faixa in timeline.faixas
|
||||
if faixa.tipo == "audio" and faixa.indice in configuracao.faixas_de_audio]
|
||||
audio = [{"indice": faixa.indice, "nome": faixa.nome,
|
||||
"clipes": [{"identificador": clipe.identificador, "nome": clipe.nome,
|
||||
"inicio": clipe.intervalo_na_timeline.inicio, "fim": clipe.intervalo_na_timeline.fim}
|
||||
for clipe in faixa.clipes]}
|
||||
for faixa in faixas_de_audio]
|
||||
caso = nome_seguro(timeline.nome)
|
||||
pasta = saida.parent / caso
|
||||
pasta.mkdir(parents=True, exist_ok=True)
|
||||
saida = pasta / f"{caso}-resultado.json"
|
||||
detalhado = pasta / "detalhado"
|
||||
detalhado.mkdir(parents=True, exist_ok=True)
|
||||
geometria = []
|
||||
for resultado in resultados:
|
||||
for frame in resultado.get("observacoes_por_frame", []):
|
||||
fatos = [item for item in frame["evidencias"] if item["tipo"] in {"rosto", "pessoa", "pose", "mao"}]
|
||||
if fatos:
|
||||
geometria.append({"clipe": resultado["clipe"]["identificador"], "timestamp_na_origem": frame["timestamp_na_origem"], "evidencias": fatos})
|
||||
frame["evidencias"] = [item for item in frame["evidencias"] if item not in fatos]
|
||||
(detalhado / "geometria.jsonl").write_text("".join(json.dumps(item, ensure_ascii=False) + "\n" for item in geometria), encoding="utf-8")
|
||||
video_arquivo = f"{caso}-video.json"
|
||||
(pasta / video_arquivo).write_text(json.dumps({"clipes": resultados}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
audio_arquivos = []
|
||||
transcricoes_por_clipe: dict[str, list[dict]] = {}
|
||||
transcricao_configurada = pedido["perfil"].get("transcricao", {})
|
||||
if faixas_de_audio and transcricao_configurada.get("caminho_modelo"):
|
||||
try:
|
||||
from engine.integracoes.whisper import ProviderDeTranscricaoLocal
|
||||
from engine.dominio import Timeline
|
||||
emitir("Transcrevendo áudio", 5)
|
||||
provider = ProviderDeTranscricaoLocal(transcricao_configurada["caminho_modelo"],
|
||||
idioma=transcricao_configurada.get("idioma", "pt"))
|
||||
diarizador = None
|
||||
if transcricao_configurada.get("diarizacao"):
|
||||
if not os.environ.get("HF_TOKEN"):
|
||||
transcricao_configurada = {**transcricao_configurada,
|
||||
"aviso_diarizacao": "Token do Hugging Face não configurado."}
|
||||
else:
|
||||
from engine.integracoes.huggingface import ProviderDeDiarizacaoHuggingFace
|
||||
modelo_diarizacao = os.environ.get("HF_DIARIZATION_MODEL", MODELO_DIARIZACAO_PADRAO)
|
||||
diarizador = ProviderDeDiarizacaoHuggingFace(modelo_diarizacao)
|
||||
transcricao_configurada = {**transcricao_configurada,
|
||||
"modelo_diarizacao": modelo_diarizacao}
|
||||
transcricoes = TranscricaoDaTimeline(provider, diretoria_de_trabalho=pasta / ".cache-audio",
|
||||
diarizador=diarizador).executar(
|
||||
Timeline(timeline.identificador, timeline.nome, faixas=faixas_de_audio))
|
||||
for transcricao in transcricoes:
|
||||
transcricoes_por_clipe[transcricao.identificador_do_clipe] = [
|
||||
{"inicio": item.inicio, "fim": item.fim, "texto": item.texto,
|
||||
"confianca": item.confianca, "falante": item.falante}
|
||||
for item in transcricao.segmentos]
|
||||
except Exception as erro:
|
||||
transcricao_configurada = {**transcricao_configurada, "erro": str(erro)}
|
||||
for faixa in audio:
|
||||
arquivo = f"{caso}-audio-faixa-{faixa['indice'] + 1}.json"
|
||||
segmentos = [{"clipe": clipe["identificador"], "segmentos": transcricoes_por_clipe.get(clipe["identificador"], [])}
|
||||
for clipe in faixa["clipes"]]
|
||||
(pasta / arquivo).write_text(json.dumps({"faixa": faixa, "transcricao": transcricao_configurada,
|
||||
"segmentos_por_clipe": segmentos,
|
||||
"status": "concluida" if transcricoes_por_clipe else "indisponivel"},
|
||||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
audio_arquivos.append(arquivo)
|
||||
saida.write_text(json.dumps({"versao": 1, "perfil": pedido["perfil"],
|
||||
"sequencia": {"id": timeline.identificador, "nome": timeline.nome},
|
||||
"artefatos": {"audio": audio_arquivos, "video": video_arquivo,
|
||||
"geometria": "detalhado/geometria.jsonl"}}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
emitir("Relatório JSON gerado", 100)
|
||||
return saida
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("entrada", type=Path)
|
||||
parser.add_argument("saida", type=Path)
|
||||
args = parser.parse_args()
|
||||
print(json.dumps({"evento": "concluido", "arquivo": str(executar(args.entrada, args.saida))}, ensure_ascii=False))
|
||||
@@ -1,7 +1,9 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from engine.integracoes.midia import ExtracaoDeAudio
|
||||
from engine.integracoes.groq import ProviderDeTranscricaoGroq
|
||||
from engine.integracoes.whisper import ProviderDeTranscricaoLocal
|
||||
from engine.integracoes.huggingface import (ProviderDeDiarizacaoHuggingFace,
|
||||
ProviderDeEmocaoHuggingFace)
|
||||
from engine.integracoes.premiere.cliente_mcp import ClienteMCPPorStdio
|
||||
from engine.integracoes.premiere.conversores import ConversorDeTimeline
|
||||
from engine.scanner.transcricao_da_timeline import TranscricaoDaTimeline
|
||||
@@ -10,7 +12,7 @@ ARQUIVO = Path('/Volumes/Merongo/PROJETOS/03 - Mastopexia/0E6A8290.MP4')
|
||||
CHAVE = json.loads((Path.home()/'.premiere-mcp/config.json').read_text()).get('groqApiKey','')
|
||||
SAIDA = Path('/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/relatorios')
|
||||
|
||||
cliente = ClienteMCPPorStdio(['node', 'dist/index.js'])
|
||||
cliente = ClienteMCPPorStdio(['node', str(Path(__file__).resolve().parents[1] / 'dist/index.js')])
|
||||
cliente.conectar()
|
||||
bruta = cliente.chamar('get_active_sequence', {})['structuredContent']['data']
|
||||
timeline = ConversorDeTimeline().converter(bruta)
|
||||
@@ -18,8 +20,13 @@ for faixa in timeline.faixas:
|
||||
for clipe in faixa.clipes:
|
||||
if clipe.nome == '0E6A8290.MP4': clipe.arquivo = str(ARQUIVO)
|
||||
|
||||
provider = ProviderDeTranscricaoGroq(CHAVE)
|
||||
transcricoes = TranscricaoDaTimeline(provider, ExtracaoDeAudio(duracao_da_parte=600), SAIDA/'audio').executar(timeline)
|
||||
MODELO = Path('/Volumes/Merongo/SISTEMAS/MODELOSIA/hf-cache/hub/models--mobiuslabsgmbh--faster-whisper-large-v3-turbo/snapshots/0a363e9161cbc7ed1431c9597a8ceaf0c4f78fcf')
|
||||
provider = ProviderDeTranscricaoLocal(str(MODELO), idioma='pt')
|
||||
MODELO_EMOCAO = Path('/Volumes/Merongo/SISTEMAS/MODELOSIA/models/wav2vec2-xls-r-300m-pt-br-spontaneous-speech-emotion-recognition')
|
||||
analisador_de_emocao = ProviderDeEmocaoHuggingFace(str(MODELO_EMOCAO))
|
||||
MODELO_DIARIZACAO = Path('/Volumes/Merongo/SISTEMAS/MODELOSIA/models/pyannote-speaker-diarization-community-1')
|
||||
diarizador = ProviderDeDiarizacaoHuggingFace(str(MODELO_DIARIZACAO))
|
||||
transcricoes = TranscricaoDaTimeline(provider, ExtracaoDeAudio(duracao_da_parte=600), SAIDA/'audio', analisador_de_emocao, diarizador).executar(timeline)
|
||||
|
||||
linhas = [f'# Relatório de transcrição — {timeline.nome}', '', f'Duração: {bruta["end"]:.3f} s',
|
||||
f'Clipes processados: {sum(len(f.clipes) for f in timeline.faixas if f.tipo == "video")}', '']
|
||||
@@ -32,5 +39,17 @@ for faixa in timeline.faixas:
|
||||
texto = resultado.texto if resultado else ''
|
||||
linhas += [f'## {clipe.identificador} — {clipe.intervalo_na_timeline.inicio:.3f}s–{clipe.intervalo_na_timeline.fim:.3f}s',
|
||||
f'Origem: {intervalo.inicio:.3f}s–{intervalo.fim:.3f}s', '', texto or '_Sem fala detectada._', '']
|
||||
if resultado and resultado.segmentos:
|
||||
linhas.append('Emoções detectadas por segmento:')
|
||||
for segmento in resultado.segmentos:
|
||||
if segmento.emocao:
|
||||
voz = f', voz {segmento.voz_aparente}' if segmento.voz_aparente else ''
|
||||
linhas.append(f'- [{segmento.inicio:.3f}s–{segmento.fim:.3f}s] {segmento.falante or "falante_indefinido"}: {segmento.emocao} ({segmento.confianca_emocao:.2f}{voz})')
|
||||
linhas.append('')
|
||||
for segmento in resultado.segmentos if resultado else []:
|
||||
for palavra in segmento.palavras:
|
||||
linhas.append(f'- [{palavra.inicio:.3f}s–{palavra.fim:.3f}s] {palavra.texto}')
|
||||
if resultado and resultado.segmentos:
|
||||
linhas.append('')
|
||||
(SAIDA/'transcricao-timeline.md').write_text('\n'.join(linhas), encoding='utf-8')
|
||||
print(SAIDA/'transcricao-timeline.md')
|
||||
|
||||
@@ -1,44 +1,82 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Speech
|
||||
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
guard CommandLine.arguments.count >= 3 else { finalizar("uso: arquivo locale [somente_no_dispositivo]", 2); return }
|
||||
let url = URL(fileURLWithPath: CommandLine.arguments[1])
|
||||
let locale = Locale(identifier: CommandLine.arguments[2])
|
||||
let somenteNoDispositivo = CommandLine.arguments.count > 3 && CommandLine.arguments[3] == "true"
|
||||
guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable else {
|
||||
finalizar("Apple Speech indisponível para o locale solicitado", 3); return
|
||||
/// Encapsula o ciclo de vida do Apple Speech para a Engine.
|
||||
///
|
||||
/// A classe não conhece o scanner, o catálogo ou a persistência. Sua única
|
||||
/// responsabilidade é converter um arquivo de mídia em JSON temporal. O
|
||||
/// processo é Foundation-only para poder ser executado como CLI sem iniciar
|
||||
/// AppKit ou NSApplication.
|
||||
final class TranscritorDeFalaApple {
|
||||
private let semaforo = DispatchSemaphore(value: 0)
|
||||
private var payload: [String: Any] = ["segmentos": []]
|
||||
private var codigoDeSaida: Int32 = 0
|
||||
private var finalizado = false
|
||||
|
||||
func executar(argumentos: [String]) -> Int32 {
|
||||
guard argumentos.count >= 3 else {
|
||||
return finalizar("uso: apple-speech-transcriber arquivo locale [somente_no_dispositivo]", 2)
|
||||
}
|
||||
SFSpeechRecognizer.requestAuthorization { status in
|
||||
guard status == .authorized else { self.finalizar("Permissão do Apple Speech não concedida", 4); return }
|
||||
let request = SFSpeechURLRecognitionRequest(url: url)
|
||||
|
||||
let arquivo = URL(fileURLWithPath: argumentos[1])
|
||||
let locale = Locale(identifier: argumentos[2])
|
||||
let somenteNoDispositivo = argumentos.count > 3 && argumentos[3] == "true"
|
||||
|
||||
guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable else {
|
||||
return finalizar("Apple Speech indisponível para o locale solicitado", 3)
|
||||
}
|
||||
|
||||
SFSpeechRecognizer.requestAuthorization { [weak self] status in
|
||||
guard let self else { return }
|
||||
guard status == .authorized else {
|
||||
self.finalizar("Permissão do Apple Speech não concedida", 4)
|
||||
return
|
||||
}
|
||||
|
||||
let request = SFSpeechURLRecognitionRequest(url: arquivo)
|
||||
request.shouldReportPartialResults = false
|
||||
if somenteNoDispositivo && recognizer.supportsOnDeviceRecognition { request.requiresOnDeviceRecognition = true }
|
||||
recognizer.recognitionTask(with: request) { result, error in
|
||||
if let result = result, result.isFinal {
|
||||
let segmentos = result.bestTranscription.segments.map {
|
||||
if somenteNoDispositivo && recognizer.supportsOnDeviceRecognition {
|
||||
request.requiresOnDeviceRecognition = true
|
||||
}
|
||||
recognizer.recognitionTask(with: request) { [weak self] resultado, erro in
|
||||
guard let self else { return }
|
||||
if let resultado, resultado.isFinal {
|
||||
self.payload["segmentos"] = resultado.bestTranscription.segments.map {
|
||||
["inicio": $0.timestamp, "fim": $0.timestamp + $0.duration,
|
||||
"texto": $0.substring, "confianca": $0.confidence] as [String: Any]
|
||||
}
|
||||
let data = try! JSONSerialization.data(withJSONObject: ["segmentos": segmentos])
|
||||
print(String(data: data, encoding: .utf8)!)
|
||||
self.finalizar(nil, 0)
|
||||
} else if let erro {
|
||||
self.finalizar("Falha no Apple Speech: \(erro.localizedDescription)", 5)
|
||||
}
|
||||
if error != nil || result?.isFinal == true { self.finalizar(nil, 0) }
|
||||
}
|
||||
}
|
||||
|
||||
semaforo.wait()
|
||||
imprimirResultado()
|
||||
return codigoDeSaida
|
||||
}
|
||||
|
||||
private func finalizar(_ mensagem: String?, _ codigo: Int32) {
|
||||
@discardableResult
|
||||
private func finalizar(_ mensagem: String?, _ codigo: Int32) -> Int32 {
|
||||
guard !finalizado else { return codigoDeSaida }
|
||||
finalizado = true
|
||||
if let mensagem { fputs(mensagem + "\n", stderr) }
|
||||
NSApplication.shared.terminate(nil)
|
||||
exit(codigo)
|
||||
codigoDeSaida = codigo
|
||||
semaforo.signal()
|
||||
return codigo
|
||||
}
|
||||
|
||||
private func imprimirResultado() {
|
||||
guard let dados = try? JSONSerialization.data(withJSONObject: payload),
|
||||
let texto = String(data: dados, encoding: .utf8) else {
|
||||
fputs("Não foi possível serializar o resultado do Apple Speech\n", stderr)
|
||||
codigoDeSaida = 6
|
||||
return
|
||||
}
|
||||
print(texto)
|
||||
}
|
||||
}
|
||||
|
||||
let app = NSApplication.shared
|
||||
let delegate = AppDelegate()
|
||||
app.delegate = delegate
|
||||
app.setActivationPolicy(.accessory)
|
||||
app.run()
|
||||
let transcritor = TranscritorDeFalaApple()
|
||||
exit(transcritor.executar(argumentos: CommandLine.arguments))
|
||||
|
||||
@@ -7,10 +7,10 @@ from ...scanner.modelos import SegmentoDeTranscricao
|
||||
|
||||
|
||||
class ProviderDeTranscricaoApple:
|
||||
"""Provider nativo macOS Speech; o áudio não passa por API Groq."""
|
||||
"""Provider nativo macOS Speech, local por padrão e sem AppKit."""
|
||||
|
||||
def __init__(self, executavel: str = "/private/tmp/AppleSpeechTranscriber.app/Contents/MacOS/apple-speech-transcriber", locale: str = "pt-BR",
|
||||
somente_no_dispositivo: bool = False) -> None:
|
||||
somente_no_dispositivo: bool = True) -> None:
|
||||
self.executavel = executavel
|
||||
self.locale = locale
|
||||
self.somente_no_dispositivo = somente_no_dispositivo
|
||||
@@ -24,5 +24,5 @@ class ProviderDeTranscricaoApple:
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
dados = json.loads(resultado.stdout)
|
||||
return [SegmentoDeTranscricao(float(s["inicio"]), float(s["fim"]), str(s["texto"]), s.get("confianca"))
|
||||
return [SegmentoDeTranscricao(float(s["inicio"]), float(s["fim"]), str(s["texto"]).strip(), s.get("confianca"))
|
||||
for s in dados.get("segmentos", [])]
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
from .provider_de_emocao_local import ProviderDeEmocaoHuggingFace
|
||||
from .provider_de_diarizacao_local import ProviderDeDiarizacaoHuggingFace
|
||||
|
||||
__all__ = ["ProviderDeDiarizacaoHuggingFace", "ProviderDeEmocaoHuggingFace"]
|
||||
@@ -0,0 +1,21 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ProviderDeDiarizacaoHuggingFace:
|
||||
"""Identifica intervalos de falas por participante em áudio local."""
|
||||
|
||||
def __init__(self, modelo: str) -> None:
|
||||
from pyannote.audio import Pipeline
|
||||
self.pipeline = Pipeline.from_pretrained(modelo)
|
||||
|
||||
def analisar(self, arquivo: str | Path) -> list[tuple[float, float, str]]:
|
||||
import soundfile as sf
|
||||
import torch
|
||||
audio, taxa = sf.read(str(arquivo), dtype="float32")
|
||||
if getattr(audio, "ndim", 1) > 1:
|
||||
audio = audio.mean(axis=1)
|
||||
saida = self.pipeline({"waveform": torch.from_numpy(audio).unsqueeze(0),
|
||||
"sample_rate": taxa})
|
||||
diarizacao = getattr(saida, "exclusive_speaker_diarization", saida)
|
||||
return [(float(turno.start), float(turno.end), str(falante))
|
||||
for turno, _, falante in diarizacao.itertracks(yield_label=True)]
|
||||
@@ -0,0 +1,35 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ProviderDeEmocaoHuggingFace:
|
||||
"""Classifica a emoção de uma fala localmente com Transformers."""
|
||||
|
||||
def __init__(self, modelo: str = "superb/wav2vec2-base-superb-er") -> None:
|
||||
from transformers import AutoFeatureExtractor, AutoModelForAudioClassification
|
||||
self.modelo = modelo
|
||||
self.processador = AutoFeatureExtractor.from_pretrained(modelo, local_files_only=True)
|
||||
self.classificador = AutoModelForAudioClassification.from_pretrained(
|
||||
modelo, local_files_only=True
|
||||
)
|
||||
self.classificador.eval()
|
||||
|
||||
def analisar(self, arquivo: str | Path, inicio: float, fim: float) -> dict[str, Any]:
|
||||
import soundfile as sf
|
||||
import torch
|
||||
audio, taxa = sf.read(str(arquivo), start=max(0, int(inicio * 16000)),
|
||||
stop=max(0, int(fim * 16000)), dtype="float32")
|
||||
if getattr(audio, "ndim", 1) > 1:
|
||||
audio = audio.mean(axis=1)
|
||||
entradas = self.processador(audio, sampling_rate=taxa, return_tensors="pt")
|
||||
with torch.no_grad():
|
||||
logits = self.classificador(**entradas).logits
|
||||
probabilidades = torch.softmax(logits[0], dim=-1)
|
||||
indice = int(torch.argmax(probabilidades))
|
||||
rotulo = self.classificador.config.id2label[indice]
|
||||
voz_aparente = {"non-neutral-female": "feminina",
|
||||
"non-neutral-male": "masculina"}.get(rotulo)
|
||||
return {"emocao": self.classificador.config.id2label[indice],
|
||||
"confianca": float(probabilidades[indice]),
|
||||
"voz_aparente": voz_aparente,
|
||||
"confianca_voz": float(probabilidades[indice]) if voz_aparente else None}
|
||||
@@ -1,3 +1,4 @@
|
||||
from .extracao_de_audio import ExtracaoDeAudio, ParteDeAudio
|
||||
from .extracao_de_metadados import ExtracaoDeMetadados, MetadadosDoArquivo
|
||||
|
||||
__all__ = ["ExtracaoDeAudio", "ParteDeAudio"]
|
||||
__all__ = ["ExtracaoDeAudio", "ExtracaoDeMetadados", "MetadadosDoArquivo", "ParteDeAudio"]
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import json
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ...scanner.modelos import ErroDeAnalise
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MetadadosDoArquivo:
|
||||
caminho: Path
|
||||
formato: str | None = None
|
||||
duracao: float | None = None
|
||||
codec_de_video: str | None = None
|
||||
codec_de_audio: str | None = None
|
||||
largura: int | None = None
|
||||
altura: int | None = None
|
||||
taxa_de_quadros: float | None = None
|
||||
canais_de_audio: int | None = None
|
||||
taxa_de_amostragem: int | None = None
|
||||
tamanho_em_bytes: int | None = None
|
||||
orientacao: str | None = None
|
||||
timecode: str | None = None
|
||||
|
||||
|
||||
class ExtracaoDeMetadados:
|
||||
"""Extrai metadados técnicos via ffprobe, com cache por caminho normalizado."""
|
||||
|
||||
def __init__(self, ffprobe: str = "ffprobe") -> None:
|
||||
self.ffprobe = ffprobe
|
||||
self._cache: dict[Path, MetadadosDoArquivo] = {}
|
||||
|
||||
def extrair(self, arquivo: str | Path) -> MetadadosDoArquivo:
|
||||
caminho = Path(arquivo).expanduser().resolve(strict=False)
|
||||
if not caminho.is_file():
|
||||
raise ErroDeAnalise("arquivo_inacessivel", "Arquivo de mídia não encontrado.", str(caminho))
|
||||
if caminho in self._cache:
|
||||
return self._cache[caminho]
|
||||
try:
|
||||
processo = subprocess.run(
|
||||
[self.ffprobe, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(caminho)],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
dados = json.loads(processo.stdout)
|
||||
except (OSError, subprocess.SubprocessError, json.JSONDecodeError) as exc:
|
||||
raise ErroDeAnalise("metadados_indisponiveis", f"Não foi possível ler os metadados: {exc}", str(caminho)) from exc
|
||||
|
||||
metadados = self._converter(caminho, dados)
|
||||
self._cache[caminho] = metadados
|
||||
return metadados
|
||||
|
||||
def limpar_cache(self) -> None:
|
||||
self._cache.clear()
|
||||
|
||||
@classmethod
|
||||
def _converter(cls, caminho: Path, dados: dict[str, Any]) -> MetadadosDoArquivo:
|
||||
formato = dados.get("format") or {}
|
||||
streams = dados.get("streams") or []
|
||||
video = next((item for item in streams if item.get("codec_type") == "video"), {})
|
||||
audio = next((item for item in streams if item.get("codec_type") == "audio"), {})
|
||||
duracao = cls._float(formato.get("duration"))
|
||||
if duracao is None:
|
||||
duracao = cls._float(video.get("duration") or audio.get("duration"))
|
||||
return MetadadosDoArquivo(
|
||||
caminho=caminho,
|
||||
formato=cls._format_name(formato.get("format_name")),
|
||||
duracao=duracao,
|
||||
codec_de_video=video.get("codec_name") or None,
|
||||
codec_de_audio=audio.get("codec_name") or None,
|
||||
largura=cls._int(video.get("width")), altura=cls._int(video.get("height")),
|
||||
taxa_de_quadros=cls._fps(video.get("avg_frame_rate") or video.get("r_frame_rate")),
|
||||
canais_de_audio=cls._int(audio.get("channels")),
|
||||
taxa_de_amostragem=cls._int(audio.get("sample_rate")),
|
||||
tamanho_em_bytes=cls._int(formato.get("size")) or caminho.stat().st_size,
|
||||
orientacao=video.get("side_data_list", [{}])[0].get("rotation") if video.get("side_data_list") else None,
|
||||
timecode=(formato.get("tags") or {}).get("timecode") or (video.get("tags") or {}).get("timecode"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _float(valor: Any) -> float | None:
|
||||
try:
|
||||
return None if valor in (None, "", "N/A") else float(valor)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _int(valor: Any) -> int | None:
|
||||
try:
|
||||
return None if valor in (None, "", "N/A") else int(valor)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _fps(valor: Any) -> float | None:
|
||||
if valor in (None, "", "0/0", "N/A"):
|
||||
return None
|
||||
try:
|
||||
return float(Fraction(str(valor)))
|
||||
except (ValueError, ZeroDivisionError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _format_name(valor: Any) -> str | None:
|
||||
return str(valor).split(",", 1)[0] if valor else None
|
||||
@@ -0,0 +1,161 @@
|
||||
# Análise visual local
|
||||
|
||||
## Configuração no painel
|
||||
|
||||
A configuração do Scanner é apresentada na aba **Scanner** do painel CEP em
|
||||
`code/cep-plugin/`. O perfil salvo segue o contrato versionado de
|
||||
`ConfiguracaoVisualDoScanner`; ele define amostragem, faixas, recursos Vision,
|
||||
cenas, continuidade, reenquadramento e interpretação antes de montar os
|
||||
adapters.
|
||||
|
||||
## Acesso à timeline do Premiere
|
||||
|
||||
Quando a análise precisar ler a timeline, a sequência ativa, as faixas ou os
|
||||
clipes do Premiere, use sempre a classe existente
|
||||
`engine.integracoes.premiere.leitura.AcessoAoEditor`. O módulo visual não deve
|
||||
criar chamadas MCP, abrir processos do Premiere ou acessar o bridge diretamente.
|
||||
|
||||
O acesso deve seguir esta composição:
|
||||
|
||||
```python
|
||||
from engine.integracoes.premiere.leitura import AcessoAoEditor, AcessoATimeline
|
||||
from engine.scanner import DescobertaDaTimeline
|
||||
from engine.integracoes.premiere.conversores import ConversorDeTimeline
|
||||
|
||||
acesso_ao_editor = AcessoAoEditor(AcessoATimeline(cliente_mcp))
|
||||
descoberta = DescobertaDaTimeline(acesso_ao_editor, ConversorDeTimeline())
|
||||
```
|
||||
|
||||
Depois, a timeline convertida entra no `ContextoDeAnalise` e o Scanner executa
|
||||
`AnaliseVisualDaTimeline`. A classe de acesso isola o MCP e preserva a separação
|
||||
entre a integração com o Premiere e os analyzers locais.
|
||||
|
||||
Não duplicar esse acesso em novos adapters ou analyzers. Para testes, injetar um
|
||||
fake de `AcessoAoEditor`/`AcessoATimeline` ou usar uma timeline de domínio pronta.
|
||||
|
||||
## Captura para análise visual
|
||||
|
||||
A análise visual não exporta um frame renderizado pelo Premiere. Para cada clipe
|
||||
de vídeo, o fluxo usa os campos retornados pelo MCP em `get_active_sequence`:
|
||||
|
||||
1. `sourceFile` identifica o arquivo original do `projectItem`.
|
||||
2. `inPoint` e `outPoint` delimitam o trecho usado pelo clipe.
|
||||
3. O extrator abre `sourceFile` localmente e amostra o primeiro frame do trecho,
|
||||
usando `inPoint` como tempo inicial na origem.
|
||||
4. Vision/OpenCV/ONNX recebem esse frame persistido, mantendo o timestamp de
|
||||
origem; nenhum frame é exportado da timeline do Premiere.
|
||||
|
||||
Portanto, `start`/`end` são tempos da timeline e `inPoint`/`outPoint` são tempos
|
||||
do arquivo original. Não misturar esses relógios ao analisar um clipe.
|
||||
|
||||
O Scanner usa apenas as interfaces em `contratos.py`. As bibliotecas externas
|
||||
ficam em adapters concretos, para que possam ser habilitadas, substituídas ou
|
||||
testadas isoladamente.
|
||||
|
||||
| Papel | Adapter |
|
||||
| --- | --- |
|
||||
| Extrair frames | `ExtratorDeQuadrosOpenCV` |
|
||||
| Medir qualidade | `AnalisadorDeQualidadeOpenCV` |
|
||||
| Rosto e olhos visíveis | `AnalisadorDeRostosOpenCV` |
|
||||
| Composição e área para legendas | `AnalisadorDeComposicaoOpenCV` |
|
||||
| Tremor/movimento de câmera | `AnalisadorDeTremorOpenCV` |
|
||||
| Continuidade e frame congelado | `AnalisadorDeContinuidadeOpenCV` |
|
||||
| Detectar objetos | `AnalisadorDeObjetosONNX` |
|
||||
| Detectar pose e mãos | `AnalisadorDePoseMediaPipe` |
|
||||
| OCR e faces macOS | `AnalisadorAppleVision` |
|
||||
| Vision + Apple Intelligence (Swift isolado) | `AnalisadorAppleVisionNativo` |
|
||||
| Similaridade temporal por feature print | `AnalisadorSequenciaAppleVisionNativo` |
|
||||
| Extrair frames para Vision sem OpenCV | `ExtratorDeQuadrosFFmpeg` |
|
||||
| Detectar cenas | `DetectorDeCenasPySceneDetect` |
|
||||
|
||||
`AnalisadorDeObjetosONNX` exige dois adapters específicos do modelo:
|
||||
`preparar(imagem, entradas)` e `decodificar(saidas, quadro)`. Isso mantém os
|
||||
detalhes de cada arquivo ONNX fora do Scanner e permite trocar de modelo sem
|
||||
alterar a interface do domínio.
|
||||
|
||||
`AnalisadorDePoseMediaPipe` recebe caminhos explícitos para os arquivos `.task`
|
||||
de pose e mãos. Os modelos ficam em `/Volumes/Merongo/SISTEMAS/MODELOSIA/mediapipe/`
|
||||
e não são acoplados ao pacote Python. Ele executa em subprocesso: falhas nativas
|
||||
do MediaPipe são contidas e retornam como indisponibilidade do adapter, sem
|
||||
encerrar o processo do Scanner.
|
||||
|
||||
`AnalisadorDeRostosOpenCV` usa os arquivos locais
|
||||
`haarcascade_frontalface_default.xml` e `haarcascade_eye.xml`. Quando a
|
||||
distribuição do OpenCV não os incluir, forneça `diretorio_de_modelos` apontando
|
||||
para a pasta que os contém.
|
||||
|
||||
`AnalisadorAppleVisionNativo` é a opção Apple recomendada. Seu runner Swift
|
||||
executa faces/landmarks, qualidade facial, pessoas, pose, mãos, OCR,
|
||||
classificação e estética de forma independente. A interpretação editorial pelo
|
||||
`FoundationModels` só é criada a partir dessas evidências e é salva em separado.
|
||||
Falhas nativas por recurso viram `diagnostico_apple_vision`, sem derrubar o
|
||||
Scanner nem descartar os fatos que funcionaram.
|
||||
|
||||
Além de rostos, pessoas, pose, mãos, OCR, categorias e estética, o runner
|
||||
Apple retorna códigos/QR, horizonte, retângulos, densidade de contornos,
|
||||
ocupação da máscara de pessoas e similaridade visual entre frames. Os valores
|
||||
são fatos normalizados; descrição, contexto e ação devem ser derivados depois,
|
||||
a partir dessas evidências temporizadas.
|
||||
|
||||
## Montagem
|
||||
|
||||
```python
|
||||
from engine.integracoes.visual import (
|
||||
AnalisadorDeQualidadeOpenCV,
|
||||
AnalisadorDeRostosOpenCV,
|
||||
AnalisadorDeComposicaoOpenCV,
|
||||
AnalisadorDeTremorOpenCV,
|
||||
AnalisadorDeContinuidadeOpenCV,
|
||||
DetectorDeCenasPySceneDetect,
|
||||
ExtratorDeQuadrosOpenCV,
|
||||
)
|
||||
from engine.scanner.visual import DetectorDeCenas
|
||||
|
||||
detector = DetectorDeCenas(
|
||||
ExtratorDeQuadrosOpenCV(intervalo_em_segundos=1.0, diretorio_de_cache=".frames"),
|
||||
analisadores_de_frame=[
|
||||
AnalisadorDeQualidadeOpenCV(), AnalisadorDeRostosOpenCV(),
|
||||
AnalisadorDeComposicaoOpenCV(),
|
||||
],
|
||||
analisadores_de_sequencia=[
|
||||
AnalisadorDeTremorOpenCV(), AnalisadorDeContinuidadeOpenCV(),
|
||||
],
|
||||
detectores_de_intervalo=[DetectorDeCenasPySceneDetect()],
|
||||
)
|
||||
```
|
||||
|
||||
Depois, passe esse `DetectorDeCenas` para `AnaliseVisualDaTimeline` ao montar o
|
||||
`PipelineDoScanner`.
|
||||
|
||||
Para o caminho local padrão, a montagem pode ser reduzida a:
|
||||
|
||||
```python
|
||||
from engine.scanner import AnaliseVisualDaTimeline, PipelineDoScanner, criar_detector_visual_local
|
||||
|
||||
pipeline = PipelineDoScanner([AnaliseVisualDaTimeline(criar_detector_visual_local())])
|
||||
contexto = pipeline.executar(contexto)
|
||||
```
|
||||
|
||||
O resultado fica em `contexto.caracteristicas_visuais` (por clipe),
|
||||
`contexto.cenas_visuais` (cenas com observações) e `contexto.avisos`.
|
||||
Quando OpenCV/FFmpeg ou outro adapter opcional não estiver disponível, o clipe
|
||||
é marcado com `disponivel=False` e os demais continuam sendo processados.
|
||||
|
||||
## Montagem recomendada para Apple Vision
|
||||
|
||||
```python
|
||||
from engine.scanner import AnaliseVisualDaTimeline, PipelineDoScanner, criar_detector_apple_vision
|
||||
|
||||
pipeline = PipelineDoScanner([
|
||||
AnaliseVisualDaTimeline(criar_detector_apple_vision(intervalo_em_segundos=1.0)),
|
||||
])
|
||||
```
|
||||
|
||||
Esse detector extrai PNGs do arquivo original com FFmpeg, usando o intervalo
|
||||
`inPoint`/`outPoint` do clipe, e não exporta imagens renderizadas pelo Premiere.
|
||||
Ele também recua a amostra no fim de vídeos muito curtos quando não há um frame
|
||||
decodificável exatamente no timestamp solicitado.
|
||||
|
||||
Os analyzers de sequência não tentam concluir a intenção de uma pessoa. Eles
|
||||
retornam indícios temporais (`possivel_tremor`, `possivel_descontinuidade` e
|
||||
`possivel_frame_congelado`) para que a camada editorial decida como tratá-los.
|
||||
@@ -0,0 +1,20 @@
|
||||
from .analisadores import (AnalisadorAppleVision, AnalisadorDeComposicaoOpenCV,
|
||||
AnalisadorDeContinuidadeOpenCV, AnalisadorDeObjetosONNX,
|
||||
AnalisadorDePoseMediaPipe, AnalisadorDeQualidadeOpenCV,
|
||||
AnalisadorDeRostosOpenCV, AnalisadorDeTremorOpenCV)
|
||||
from .apple_vision import (AnalisadorAppleVisionNativo, AnalisadorSequenciaAppleVisionNativo,
|
||||
ExecutorDoRunnerApple)
|
||||
from .contratos import (AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros,
|
||||
DetectorDeIntervalosDeCena, ExtratorDeQuadros)
|
||||
from .detectores_de_cena import DetectorDeCenasPySceneDetect
|
||||
from .extrator_open_cv import ExtratorDeQuadrosOpenCV
|
||||
from .extrator_ffmpeg import ExtratorDeQuadrosFFmpeg
|
||||
from .modelos import QuadroDeVideo
|
||||
|
||||
__all__ = ["AnalisadorAppleVision", "AnalisadorAppleVisionNativo", "AnalisadorSequenciaAppleVisionNativo", "AnalisadorDeComposicaoOpenCV",
|
||||
"AnalisadorDeContinuidadeOpenCV", "AnalisadorDeFrame",
|
||||
"AnalisadorDeObjetosONNX", "AnalisadorDePoseMediaPipe",
|
||||
"AnalisadorDeQualidadeOpenCV", "AnalisadorDeRostosOpenCV",
|
||||
"AnalisadorDeSequenciaDeQuadros", "AnalisadorDeTremorOpenCV",
|
||||
"DetectorDeCenasPySceneDetect", "DetectorDeIntervalosDeCena",
|
||||
"ExecutorDoRunnerApple", "ExtratorDeQuadros", "ExtratorDeQuadrosFFmpeg", "ExtratorDeQuadrosOpenCV", "QuadroDeVideo"]
|
||||
@@ -0,0 +1,331 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Callable
|
||||
|
||||
from ...scanner.modelos import EvidenciaVisual
|
||||
from .contratos import AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros
|
||||
from .modelos import QuadroDeVideo
|
||||
|
||||
|
||||
class AnalisadorDeQualidadeOpenCV(AnalisadorDeFrame):
|
||||
"""Mede nitidez, brilho e contraste de cada frame usando OpenCV."""
|
||||
|
||||
nome = "opencv"
|
||||
|
||||
def __init__(self, cv2_module: Any | None = None) -> None:
|
||||
self._cv2 = cv2_module
|
||||
|
||||
@property
|
||||
def cv2(self) -> Any:
|
||||
if self._cv2 is None:
|
||||
try:
|
||||
import cv2
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("OpenCV não está instalado. Instale opencv-python.") from exc
|
||||
self._cv2 = cv2
|
||||
return self._cv2
|
||||
|
||||
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
||||
imagem = quadro.imagem
|
||||
cinza = self.cv2.cvtColor(imagem, self.cv2.COLOR_BGR2GRAY) if len(imagem.shape) == 3 else imagem
|
||||
media, desvio = self.cv2.meanStdDev(cinza)
|
||||
nitidez = float(self.cv2.Laplacian(cinza, self.cv2.CV_64F).var())
|
||||
valor = {
|
||||
"largura": quadro.largura,
|
||||
"altura": quadro.altura,
|
||||
"brilho": float(media[0][0]),
|
||||
"contraste": float(desvio[0][0]),
|
||||
"nitidez_laplaciana": nitidez,
|
||||
}
|
||||
return [EvidenciaVisual("qualidade", quadro.timestamp, quadro.timestamp, valor,
|
||||
provider=self.nome)]
|
||||
|
||||
|
||||
class _AdapterOpenCV:
|
||||
"""Implementação compartilhada para adapters OpenCV, com importação tardia."""
|
||||
|
||||
def __init__(self, cv2_module: Any | None = None) -> None:
|
||||
self._cv2 = cv2_module
|
||||
|
||||
@property
|
||||
def cv2(self) -> Any:
|
||||
if self._cv2 is None:
|
||||
try:
|
||||
import cv2
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("OpenCV não está instalado. Instale opencv-python.") from exc
|
||||
self._cv2 = cv2
|
||||
return self._cv2
|
||||
|
||||
|
||||
class AnalisadorDeRostosOpenCV(_AdapterOpenCV, AnalisadorDeFrame):
|
||||
"""Encontra rostos e olhos localmente; não infere identidade nem emoção."""
|
||||
|
||||
nome = "opencv_haar"
|
||||
|
||||
def __init__(self, escala: float = 1.1, vizinhos_minimos: int = 5,
|
||||
diretorio_de_modelos: str | Path | None = None,
|
||||
tamanho_minimo_relativo: float = 0.04,
|
||||
cv2_module: Any | None = None) -> None:
|
||||
super().__init__(cv2_module)
|
||||
self.escala = escala
|
||||
self.vizinhos_minimos = vizinhos_minimos
|
||||
self.diretorio_de_modelos = Path(diretorio_de_modelos) if diretorio_de_modelos else None
|
||||
self.tamanho_minimo_relativo = tamanho_minimo_relativo
|
||||
self._detectores: tuple[Any, Any] | None = None
|
||||
|
||||
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
||||
detector_de_rostos, detector_de_olhos = self._obter_detectores()
|
||||
cinza = self.cv2.cvtColor(quadro.imagem, self.cv2.COLOR_BGR2GRAY)
|
||||
rostos = detector_de_rostos.detectMultiScale(cinza, scaleFactor=self.escala,
|
||||
minNeighbors=self.vizinhos_minimos)
|
||||
evidencias: list[EvidenciaVisual] = []
|
||||
for x, y, largura, altura in rostos:
|
||||
caixa = _caixa_normalizada(x, y, largura, altura, quadro.largura, quadro.altura)
|
||||
if min(caixa["largura"], caixa["altura"]) < self.tamanho_minimo_relativo:
|
||||
continue
|
||||
rosto = EvidenciaVisual("rosto", quadro.timestamp, quadro.timestamp,
|
||||
{"bounding_box": caixa, "proximo_da_borda": _proximo_da_borda(caixa)}, provider=self.nome)
|
||||
olhos = detector_de_olhos.detectMultiScale(cinza[y:y + altura, x:x + largura],
|
||||
scaleFactor=1.1, minNeighbors=4)
|
||||
evidencias.extend((rosto, EvidenciaVisual("olhos_visiveis", quadro.timestamp, quadro.timestamp,
|
||||
{"quantidade": len(olhos), "rosto": caixa}, provider=self.nome)))
|
||||
return evidencias
|
||||
|
||||
def _obter_detectores(self) -> tuple[Any, Any]:
|
||||
if self._detectores is None:
|
||||
base = self.diretorio_de_modelos or Path(self.cv2.data.haarcascades)
|
||||
rostos = self.cv2.CascadeClassifier(str(base / "haarcascade_frontalface_default.xml"))
|
||||
olhos = self.cv2.CascadeClassifier(str(base / "haarcascade_eye.xml"))
|
||||
if rostos.empty() or olhos.empty():
|
||||
raise RuntimeError(f"Cascades Haar do OpenCV não estão disponíveis em: {base}")
|
||||
self._detectores = rostos, olhos
|
||||
return self._detectores
|
||||
|
||||
|
||||
class AnalisadorDeComposicaoOpenCV(_AdapterOpenCV, AnalisadorDeFrame):
|
||||
"""Mede orientação, poluição visual e disponibilidade das zonas para legendas."""
|
||||
|
||||
nome = "opencv_composicao"
|
||||
|
||||
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
||||
cinza = self.cv2.cvtColor(quadro.imagem, self.cv2.COLOR_BGR2GRAY)
|
||||
bordas = self.cv2.Canny(cinza, 80, 160)
|
||||
densidade_de_bordas = float((bordas > 0).mean())
|
||||
terco_inferior = cinza[int(quadro.altura * 2 / 3):, :]
|
||||
zona_de_legenda_livre = float((self.cv2.Canny(terco_inferior, 80, 160) > 0).mean()) < 0.08
|
||||
valor = {
|
||||
"orientacao": "vertical" if quadro.altura > quadro.largura else "horizontal",
|
||||
"densidade_de_bordas": densidade_de_bordas,
|
||||
"fundo_visual_poluido": densidade_de_bordas > 0.16,
|
||||
"zona_inferior_livre_para_legenda": zona_de_legenda_livre,
|
||||
}
|
||||
return [EvidenciaVisual("composicao", quadro.timestamp, quadro.timestamp, valor, provider=self.nome)]
|
||||
|
||||
|
||||
class AnalisadorDeTremorOpenCV(_AdapterOpenCV, AnalisadorDeSequenciaDeQuadros):
|
||||
"""Estima deslocamento global entre frames para sinalizar possível tremor de câmera."""
|
||||
|
||||
nome = "opencv_movimento"
|
||||
|
||||
def analisar(self, quadros: list[QuadroDeVideo]) -> list[EvidenciaVisual]:
|
||||
if len(quadros) < 2:
|
||||
return []
|
||||
deslocamentos = [_deslocamento_global(self.cv2, anterior.imagem, atual.imagem)
|
||||
for anterior, atual in zip(quadros, quadros[1:])]
|
||||
deslocamentos = [item for item in deslocamentos if item is not None]
|
||||
if not deslocamentos:
|
||||
return []
|
||||
medio = sum(deslocamentos) / len(deslocamentos)
|
||||
variacao = sum(abs(item - medio) for item in deslocamentos) / len(deslocamentos)
|
||||
inicio, fim = quadros[0].timestamp, quadros[-1].timestamp
|
||||
return [EvidenciaVisual("movimento_de_camera", inicio, fim, {
|
||||
"deslocamento_medio_pixels": medio,
|
||||
"variacao_do_deslocamento": variacao,
|
||||
"possivel_tremor": variacao > 2.0 and medio > 1.0,
|
||||
"amostras": len(deslocamentos),
|
||||
}, provider=self.nome)]
|
||||
|
||||
|
||||
class AnalisadorDeContinuidadeOpenCV(_AdapterOpenCV, AnalisadorDeSequenciaDeQuadros):
|
||||
"""Compara pares de frames e retorna indícios, não certezas, de descontinuidade ou congelamento."""
|
||||
|
||||
nome = "opencv_continuidade"
|
||||
|
||||
def analisar(self, quadros: list[QuadroDeVideo]) -> list[EvidenciaVisual]:
|
||||
evidencias: list[EvidenciaVisual] = []
|
||||
for anterior, atual in zip(quadros, quadros[1:]):
|
||||
cinza_anterior = self.cv2.cvtColor(anterior.imagem, self.cv2.COLOR_BGR2GRAY)
|
||||
cinza_atual = self.cv2.cvtColor(atual.imagem, self.cv2.COLOR_BGR2GRAY)
|
||||
diferenca = float(self.cv2.absdiff(cinza_anterior, cinza_atual).mean())
|
||||
evidencias.append(EvidenciaVisual("continuidade", anterior.timestamp, atual.timestamp, {
|
||||
"diferenca_media_de_luminancia": diferenca,
|
||||
"possivel_frame_congelado": diferenca < 0.8,
|
||||
"possivel_descontinuidade": diferenca > 38.0,
|
||||
}, provider=self.nome))
|
||||
return evidencias
|
||||
|
||||
|
||||
class AnalisadorDeObjetosONNX(AnalisadorDeFrame):
|
||||
"""Adapter de modelo ONNX; pré e pós-processamento pertencem ao modelo escolhido."""
|
||||
|
||||
nome = "onnxruntime"
|
||||
|
||||
def __init__(self, modelo: str | Path, preparar: Callable[[Any, list[str]], dict[str, Any]],
|
||||
decodificar: Callable[[list[Any], QuadroDeVideo], list[dict[str, Any]]],
|
||||
usar_coreml: bool = True, ort_module: Any | None = None) -> None:
|
||||
self.modelo = str(modelo)
|
||||
self.preparar = preparar
|
||||
self.decodificar = decodificar
|
||||
self._ort = ort_module
|
||||
ort = self.ort
|
||||
preferidos = ["CoreMLExecutionProvider", "CPUExecutionProvider"] if usar_coreml else ["CPUExecutionProvider"]
|
||||
disponiveis = set(ort.get_available_providers())
|
||||
self.providers = [provider for provider in preferidos if provider in disponiveis]
|
||||
if not self.providers:
|
||||
raise RuntimeError("ONNX Runtime não possui provider compatível neste ambiente.")
|
||||
self.sessao = ort.InferenceSession(self.modelo, providers=self.providers)
|
||||
self.entradas = [entrada.name for entrada in self.sessao.get_inputs()]
|
||||
|
||||
@property
|
||||
def ort(self) -> Any:
|
||||
if self._ort is None:
|
||||
try:
|
||||
import onnxruntime
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("ONNX Runtime não está instalado. Instale onnxruntime.") from exc
|
||||
self._ort = onnxruntime
|
||||
return self._ort
|
||||
|
||||
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
||||
entradas = self.preparar(quadro.imagem, self.entradas)
|
||||
saidas = self.sessao.run(None, entradas)
|
||||
deteccoes = self.decodificar(saidas, quadro)
|
||||
return [EvidenciaVisual("objeto", quadro.timestamp, quadro.timestamp, deteccao,
|
||||
confianca=deteccao.get("confianca"), provider=self.nome,
|
||||
modelo=Path(self.modelo).name) for deteccao in deteccoes]
|
||||
|
||||
|
||||
class AnalisadorDePoseMediaPipe(AnalisadorDeFrame):
|
||||
"""Adapter MediaPipe Tasks para pose e mãos em um frame."""
|
||||
|
||||
nome = "mediapipe"
|
||||
|
||||
def __init__(self, modelo_de_pose: str | Path | None = None,
|
||||
modelo_de_maos: str | Path | None = None) -> None:
|
||||
self.modelo_de_pose = Path(modelo_de_pose) if modelo_de_pose else None
|
||||
self.modelo_de_maos = Path(modelo_de_maos) if modelo_de_maos else None
|
||||
if self.modelo_de_pose is None and self.modelo_de_maos is None:
|
||||
raise ValueError("Informe ao menos um modelo MediaPipe de pose ou mãos.")
|
||||
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
||||
if quadro.caminho is None:
|
||||
raise ValueError("MediaPipe requer que o frame tenha caminho persistido em disco.")
|
||||
for modelo in (self.modelo_de_pose, self.modelo_de_maos):
|
||||
if modelo:
|
||||
self._validar_modelo(modelo)
|
||||
carga = {
|
||||
"frame": str(quadro.caminho), "pose": str(self.modelo_de_pose) if self.modelo_de_pose else None,
|
||||
"maos": str(self.modelo_de_maos) if self.modelo_de_maos else None,
|
||||
}
|
||||
processo = subprocess.run(
|
||||
[sys.executable, "-m", "engine.integracoes.visual.mediapipe_runner"],
|
||||
input=json.dumps(carga), capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
if processo.returncode != 0:
|
||||
detalhe = processo.stderr.strip().splitlines()[-1] if processo.stderr.strip() else "falha nativa sem diagnóstico"
|
||||
raise RuntimeError(f"MediaPipe falhou em processo isolado (código {processo.returncode}): {detalhe}")
|
||||
dados = json.loads(processo.stdout)
|
||||
return [EvidenciaVisual(item["tipo"], quadro.timestamp, quadro.timestamp, item["valor"],
|
||||
provider=self.nome) for item in dados]
|
||||
|
||||
@staticmethod
|
||||
def _validar_modelo(caminho: Path) -> None:
|
||||
if not caminho.is_file():
|
||||
raise FileNotFoundError(f"Modelo MediaPipe não encontrado: {caminho}")
|
||||
|
||||
|
||||
class AnalisadorAppleVision(AnalisadorDeFrame):
|
||||
"""Usa PyObjC/Vision para OCR e detecção de faces em frames persistidos."""
|
||||
|
||||
nome = "apple_vision"
|
||||
|
||||
def __init__(self, reconhecer_texto: bool = True, detectar_faces: bool = True) -> None:
|
||||
self.reconhecer_texto = reconhecer_texto
|
||||
self.detectar_faces = detectar_faces
|
||||
|
||||
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
||||
if quadro.caminho is None:
|
||||
raise ValueError("Apple Vision requer que o frame tenha caminho persistido em disco.")
|
||||
try:
|
||||
from Foundation import NSURL
|
||||
from Vision import VNDetectFaceRectanglesRequest, VNImageRequestHandler, VNRecognizeTextRequest
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("Apple Vision requer PyObjC e macOS.") from exc
|
||||
requisicoes = []
|
||||
texto = VNRecognizeTextRequest.alloc().initWithCompletionHandler_(None) if self.reconhecer_texto else None
|
||||
faces = VNDetectFaceRectanglesRequest.alloc().initWithCompletionHandler_(None) if self.detectar_faces else None
|
||||
if texto:
|
||||
requisicoes.append(texto)
|
||||
if faces:
|
||||
requisicoes.append(faces)
|
||||
handler = VNImageRequestHandler.alloc().initWithURL_options_(NSURL.fileURLWithPath_(str(quadro.caminho)), {})
|
||||
sucesso, erro = handler.performRequests_error_(requisicoes, None)
|
||||
if not sucesso:
|
||||
detalhe = str(erro) if erro else "o macOS não retornou detalhe; verifique Vision/Neural Engine no host"
|
||||
raise RuntimeError(f"Apple Vision falhou: {detalhe}")
|
||||
evidencias: list[EvidenciaVisual] = []
|
||||
if texto:
|
||||
for observacao in texto.results() or []:
|
||||
candidatos = observacao.topCandidates_(1)
|
||||
if candidatos:
|
||||
candidato = candidatos[0]
|
||||
evidencias.append(EvidenciaVisual("ocr", quadro.timestamp, quadro.timestamp,
|
||||
{"texto": str(candidato.string()), "bounding_box": _retangulo(observacao.boundingBox())},
|
||||
confianca=float(candidato.confidence()), provider=self.nome))
|
||||
if faces:
|
||||
for observacao in faces.results() or []:
|
||||
evidencias.append(EvidenciaVisual("rosto", quadro.timestamp, quadro.timestamp,
|
||||
{"bounding_box": _retangulo(observacao.boundingBox())}, provider=self.nome))
|
||||
return evidencias
|
||||
|
||||
|
||||
def _retangulo(retangulo: Any) -> dict[str, float]:
|
||||
return {"x": float(retangulo.origin.x), "y": float(retangulo.origin.y),
|
||||
"largura": float(retangulo.size.width), "altura": float(retangulo.size.height)}
|
||||
|
||||
|
||||
def _caixa_normalizada(x: int, y: int, largura: int, altura: int,
|
||||
largura_do_frame: int, altura_do_frame: int) -> dict[str, float]:
|
||||
return {"x": float(x / largura_do_frame), "y": float(y / altura_do_frame),
|
||||
"largura": float(largura / largura_do_frame), "altura": float(altura / altura_do_frame)}
|
||||
|
||||
|
||||
def _proximo_da_borda(caixa: dict[str, float], margem: float = 0.04) -> bool:
|
||||
return (caixa["x"] < margem or caixa["y"] < margem
|
||||
or caixa["x"] + caixa["largura"] > 1 - margem
|
||||
or caixa["y"] + caixa["altura"] > 1 - margem)
|
||||
|
||||
|
||||
def _deslocamento_global(cv2: Any, imagem_anterior: Any, imagem_atual: Any) -> float | None:
|
||||
"""Retorna o módulo do deslocamento de câmera, descartando pares sem pontos úteis."""
|
||||
import math
|
||||
|
||||
anterior = cv2.cvtColor(imagem_anterior, cv2.COLOR_BGR2GRAY)
|
||||
atual = cv2.cvtColor(imagem_atual, cv2.COLOR_BGR2GRAY)
|
||||
pontos = cv2.goodFeaturesToTrack(anterior, maxCorners=150, qualityLevel=0.01,
|
||||
minDistance=12, blockSize=7)
|
||||
if pontos is None or len(pontos) < 8:
|
||||
return None
|
||||
encontrados, status, _ = cv2.calcOpticalFlowPyrLK(anterior, atual, pontos, None)
|
||||
if encontrados is None or status is None:
|
||||
return None
|
||||
origem = pontos[status.ravel() == 1]
|
||||
destino = encontrados[status.ravel() == 1]
|
||||
if len(origem) < 8:
|
||||
return None
|
||||
matriz, _ = cv2.estimateAffinePartial2D(origem, destino, method=cv2.RANSAC)
|
||||
if matriz is None:
|
||||
return None
|
||||
return math.hypot(float(matriz[0, 2]), float(matriz[1, 2]))
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Adapter Python para o runner Swift que concentra Vision e Apple Intelligence."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Any, Callable
|
||||
|
||||
from ...scanner.modelos import EvidenciaVisual
|
||||
from .contratos import AnalisadorDeFrame, AnalisadorDeSequenciaDeQuadros
|
||||
from .modelos import QuadroDeVideo
|
||||
|
||||
|
||||
RECURSOS_PADRAO = (
|
||||
"faces", "qualidade_facial", "pessoas", "pose", "maos", "ocr", "categorias",
|
||||
"estetica", "codigos", "horizonte", "retangulos", "contornos", "segmentacao_de_pessoas",
|
||||
)
|
||||
|
||||
|
||||
class ExecutorDoRunnerApple:
|
||||
"""Compila o runner Swift quando necessário e o executa em processo isolado."""
|
||||
|
||||
def __init__(self, fonte: str | Path | None = None, compilador: str = "swiftc",
|
||||
diretorio_de_build: str | Path | None = None) -> None:
|
||||
self.fonte = Path(fonte) if fonte else Path(__file__).with_name("apple_vision_runner.swift")
|
||||
self.compilador = compilador
|
||||
self.diretorio_de_build = Path(diretorio_de_build) if diretorio_de_build else (
|
||||
Path(tempfile.gettempdir()) / "jhonny-apple-vision")
|
||||
|
||||
def executar(self, carga: dict[str, Any], timeout: float) -> dict[str, Any]:
|
||||
executavel = self._garantir_compilado()
|
||||
processo = subprocess.run([str(executavel)], input=json.dumps(carga), capture_output=True,
|
||||
text=True, timeout=timeout)
|
||||
if processo.returncode != 0:
|
||||
detalhe = processo.stderr.strip() or "runner Apple terminou sem diagnóstico"
|
||||
raise RuntimeError(f"Runner Apple Vision falhou: {detalhe}")
|
||||
try:
|
||||
return json.loads(processo.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"Runner Apple Vision devolveu JSON inválido: {processo.stdout!r}") from exc
|
||||
|
||||
def _garantir_compilado(self) -> Path:
|
||||
if not self.fonte.is_file():
|
||||
raise FileNotFoundError(f"Fonte do runner Apple não encontrada: {self.fonte}")
|
||||
self.diretorio_de_build.mkdir(parents=True, exist_ok=True)
|
||||
executavel = self.diretorio_de_build / "apple-vision-runner"
|
||||
if not executavel.exists() or executavel.stat().st_mtime < self.fonte.stat().st_mtime:
|
||||
ambiente = os.environ | {"CLANG_MODULE_CACHE_PATH": str(self.diretorio_de_build / "module-cache")}
|
||||
processo = subprocess.run([self.compilador, "-parse-as-library", str(self.fonte), "-o", str(executavel)],
|
||||
capture_output=True, text=True, timeout=120, env=ambiente)
|
||||
if processo.returncode != 0:
|
||||
raise RuntimeError(f"Não foi possível compilar runner Apple Vision: {processo.stderr.strip()}")
|
||||
return executavel
|
||||
|
||||
|
||||
class AnalisadorAppleVisionNativo(AnalisadorDeFrame):
|
||||
"""Módulo profundo: pede fatos visuais nativos e opcionalmente interpretação local.
|
||||
|
||||
A interface recebe somente um `QuadroDeVideo`; Vision, Foundation Models,
|
||||
Swift, compilação e erros nativos permanecem atrás deste adapter.
|
||||
"""
|
||||
|
||||
nome = "apple_vision"
|
||||
|
||||
def __init__(self, recursos: tuple[str, ...] = RECURSOS_PADRAO,
|
||||
interpretar_com_apple_intelligence: bool = True,
|
||||
executor: ExecutorDoRunnerApple | None = None,
|
||||
timeout_em_segundos: float = 90.0) -> None:
|
||||
self.recursos = recursos
|
||||
self.interpretar_com_apple_intelligence = interpretar_com_apple_intelligence
|
||||
self.executor = executor or ExecutorDoRunnerApple()
|
||||
self.timeout_em_segundos = timeout_em_segundos
|
||||
|
||||
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]:
|
||||
if quadro.caminho is None:
|
||||
raise ValueError("Apple Vision requer que o frame tenha caminho persistido em disco.")
|
||||
dados = self.executor.executar({"frame": str(quadro.caminho), "recursos": list(self.recursos),
|
||||
"resumir": self.interpretar_com_apple_intelligence},
|
||||
self.timeout_em_segundos)
|
||||
evidencias = [self._converter(item, quadro) for item in dados.get("evidencias", [])]
|
||||
avisos = dados.get("avisos", [])
|
||||
if avisos:
|
||||
evidencias.append(EvidenciaVisual("diagnostico_apple_vision", quadro.timestamp, quadro.timestamp,
|
||||
{"avisos": avisos, "recursos_solicitados": list(self.recursos)}, provider=self.nome))
|
||||
return evidencias
|
||||
|
||||
def _converter(self, item: dict[str, Any], quadro: QuadroDeVideo) -> EvidenciaVisual:
|
||||
return EvidenciaVisual(item["tipo"], quadro.timestamp, quadro.timestamp, item["valor"],
|
||||
confianca=item.get("confianca"), provider=self.nome,
|
||||
modelo=item.get("modelo"))
|
||||
|
||||
|
||||
class AnalisadorSequenciaAppleVisionNativo(AnalisadorDeSequenciaDeQuadros):
|
||||
"""Compara frames com feature prints nativos sem expor o protocolo Swift."""
|
||||
|
||||
nome = "apple_vision_sequencia"
|
||||
|
||||
def __init__(self, executor: ExecutorDoRunnerApple | None = None,
|
||||
timeout_em_segundos: float = 90.0) -> None:
|
||||
self.executor = executor or ExecutorDoRunnerApple()
|
||||
self.timeout_em_segundos = timeout_em_segundos
|
||||
|
||||
def analisar(self, quadros: list[QuadroDeVideo]) -> list[EvidenciaVisual]:
|
||||
if len(quadros) < 2:
|
||||
return []
|
||||
if any(quadro.caminho is None for quadro in quadros):
|
||||
raise ValueError("Apple Vision de sequência requer frames persistidos em disco.")
|
||||
dados = self.executor.executar({"frames": [str(quadro.caminho) for quadro in quadros],
|
||||
"recursos": [], "resumir": False}, self.timeout_em_segundos)
|
||||
evidencias: list[EvidenciaVisual] = []
|
||||
for item in dados.get("evidencias", []):
|
||||
valor = dict(item["valor"])
|
||||
inicio = int(valor.pop("frame_inicial", 0))
|
||||
fim = int(valor.pop("frame_final", inicio + 1))
|
||||
if inicio < 0 or fim >= len(quadros) or fim < inicio:
|
||||
raise RuntimeError("Runner Apple Vision devolveu índices temporais inválidos.")
|
||||
evidencias.append(EvidenciaVisual(item["tipo"], quadros[inicio].timestamp,
|
||||
quadros[fim].timestamp, valor,
|
||||
confianca=item.get("confianca"), provider=self.nome,
|
||||
modelo=item.get("modelo")))
|
||||
avisos = dados.get("avisos", [])
|
||||
if avisos:
|
||||
evidencias.append(EvidenciaVisual("diagnostico_apple_vision", quadros[0].timestamp,
|
||||
quadros[-1].timestamp, {"avisos": avisos}, provider=self.nome))
|
||||
return evidencias
|
||||
@@ -0,0 +1,368 @@
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import Vision
|
||||
import FoundationModels
|
||||
import CoreVideo
|
||||
|
||||
struct Entrada: Decodable {
|
||||
let frame: String?
|
||||
let frames: [String]?
|
||||
let recursos: [String]
|
||||
let resumir: Bool
|
||||
}
|
||||
|
||||
struct Evidencia: Encodable {
|
||||
let tipo: String
|
||||
let valor: [String: JSONValue]
|
||||
let confianca: Double?
|
||||
let modelo: String?
|
||||
}
|
||||
|
||||
struct Saida: Encodable {
|
||||
let evidencias: [Evidencia]
|
||||
let avisos: [String]
|
||||
}
|
||||
|
||||
enum JSONValue: Encodable {
|
||||
case texto(String), numero(Double), booleano(Bool), objeto([String: JSONValue]), lista([JSONValue]), nulo
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .texto(let value): try container.encode(value)
|
||||
case .numero(let value): try container.encode(value)
|
||||
case .booleano(let value): try container.encode(value)
|
||||
case .objeto(let value): try container.encode(value)
|
||||
case .lista(let value): try container.encode(value)
|
||||
case .nulo: try container.encodeNil()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func caixa(_ rect: CGRect) -> [String: JSONValue] {
|
||||
["x": .numero(rect.origin.x), "y": .numero(rect.origin.y),
|
||||
"largura": .numero(rect.size.width), "altura": .numero(rect.size.height)]
|
||||
}
|
||||
|
||||
func ponto(_ point: CGPoint) -> JSONValue {
|
||||
.objeto(["x": .numero(point.x), "y": .numero(point.y)])
|
||||
}
|
||||
|
||||
func ponto(_ point: VNRecognizedPoint) -> JSONValue {
|
||||
.objeto(["x": .numero(point.location.x), "y": .numero(point.location.y),
|
||||
"confianca": .numero(Double(point.confidence))])
|
||||
}
|
||||
|
||||
func executar<T: VNRequest>(_ request: T, em url: URL) throws -> [VNObservation] {
|
||||
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
|
||||
let imagem = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
|
||||
throw NSError(domain: "JhonnyAppleVision", code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "ImageIO não conseguiu decodificar o frame"])
|
||||
}
|
||||
let handler = VNImageRequestHandler(cgImage: imagem, options: [:])
|
||||
try handler.perform([request])
|
||||
return request.results ?? []
|
||||
}
|
||||
|
||||
func coberturaDaMascara(_ pixelBuffer: CVPixelBuffer) -> Double {
|
||||
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
|
||||
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
|
||||
guard let base = CVPixelBufferGetBaseAddress(pixelBuffer) else { return 0 }
|
||||
let altura = CVPixelBufferGetHeight(pixelBuffer)
|
||||
let largura = CVPixelBufferGetWidth(pixelBuffer)
|
||||
let stride = CVPixelBufferGetBytesPerRow(pixelBuffer)
|
||||
let bytes = base.assumingMemoryBound(to: UInt8.self)
|
||||
var ocupados = 0
|
||||
for y in 0..<altura {
|
||||
for x in 0..<largura where bytes[y * stride + x] > 12 { ocupados += 1 }
|
||||
}
|
||||
return Double(ocupados) / Double(max(1, altura * largura))
|
||||
}
|
||||
|
||||
func analisarVision(_ entrada: Entrada) -> Saida {
|
||||
guard let frame = entrada.frame else {
|
||||
return Saida(evidencias: [], avisos: ["frame: caminho obrigatório para análise individual"])
|
||||
}
|
||||
let url = URL(fileURLWithPath: frame)
|
||||
var evidencias: [Evidencia] = []
|
||||
var avisos: [String] = []
|
||||
|
||||
func tentar(_ recurso: String, _ bloco: () throws -> [Evidencia]) {
|
||||
guard entrada.recursos.contains(recurso) else { return }
|
||||
do { evidencias.append(contentsOf: try bloco()) }
|
||||
catch { avisos.append("\(recurso): \(error.localizedDescription)") }
|
||||
}
|
||||
|
||||
tentar("faces") {
|
||||
let request = VNDetectFaceLandmarksRequest()
|
||||
return try executar(request, em: url).compactMap { observacao -> Evidencia? in
|
||||
guard let face = observacao as? VNFaceObservation else { return nil }
|
||||
var valor: [String: JSONValue] = ["bounding_box": .objeto(caixa(face.boundingBox))]
|
||||
if let landmarks = face.landmarks {
|
||||
let grupos: [(String, VNFaceLandmarkRegion2D?)] = [
|
||||
("olho_esquerdo", landmarks.leftEye), ("olho_direito", landmarks.rightEye),
|
||||
("sobrancelha_esquerda", landmarks.leftEyebrow), ("sobrancelha_direita", landmarks.rightEyebrow),
|
||||
("nariz", landmarks.nose), ("labios", landmarks.outerLips), ("rosto", landmarks.faceContour)
|
||||
]
|
||||
valor["landmarks"] = .objeto(Dictionary(uniqueKeysWithValues: grupos.compactMap { nome, regiao in
|
||||
guard let regiao else { return nil }
|
||||
return (nome, .lista(regiao.normalizedPoints.map { .objeto(["x": .numero($0.x), "y": .numero($0.y)]) }))
|
||||
}))
|
||||
}
|
||||
return Evidencia(tipo: "rosto", valor: valor, confianca: Double(face.confidence), modelo: "VNDetectFaceLandmarksRequest")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("qualidade_facial") {
|
||||
let request = VNDetectFaceCaptureQualityRequest()
|
||||
return try executar(request, em: url).compactMap { observacao -> Evidencia? in
|
||||
guard let face = observacao as? VNFaceObservation, let qualidade = face.faceCaptureQuality else { return nil }
|
||||
return Evidencia(tipo: "qualidade_do_rosto", valor: ["bounding_box": .objeto(caixa(face.boundingBox)),
|
||||
"score": .numero(Double(qualidade))], confianca: Double(face.confidence), modelo: "VNDetectFaceCaptureQualityRequest")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("pessoas") {
|
||||
let request = VNDetectHumanRectanglesRequest()
|
||||
request.upperBodyOnly = false
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
guard let pessoa = observacao as? VNHumanObservation else { return nil }
|
||||
return Evidencia(tipo: "pessoa", valor: ["bounding_box": .objeto(caixa(pessoa.boundingBox)),
|
||||
"ocupacao_do_quadro": .numero(pessoa.boundingBox.width * pessoa.boundingBox.height)],
|
||||
confianca: Double(pessoa.confidence), modelo: "VNDetectHumanRectanglesRequest")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("pose") {
|
||||
let request = VNDetectHumanBodyPoseRequest()
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
guard let pose = observacao as? VNHumanBodyPoseObservation else { return nil }
|
||||
let pontos = try? pose.recognizedPoints(.all)
|
||||
let dados: [String: JSONValue] = pontos.map { Dictionary(uniqueKeysWithValues: $0.map { (String(describing: $0.key), ponto($0.value)) }) } ?? [:]
|
||||
return Evidencia(tipo: "pose", valor: ["pontos": .objeto(dados)], confianca: Double(pose.confidence), modelo: "VNDetectHumanBodyPoseRequest")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("maos") {
|
||||
let request = VNDetectHumanHandPoseRequest()
|
||||
request.maximumHandCount = 4
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
guard let mao = observacao as? VNHumanHandPoseObservation else { return nil }
|
||||
let pontos = try? mao.recognizedPoints(.all)
|
||||
let dados: [String: JSONValue] = pontos.map { Dictionary(uniqueKeysWithValues: $0.map { (String(describing: $0.key), ponto($0.value)) }) } ?? [:]
|
||||
return Evidencia(tipo: "mao", valor: ["pontos": .objeto(dados)], confianca: Double(mao.confidence), modelo: "VNDetectHumanHandPoseRequest")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("ocr") {
|
||||
let request = VNRecognizeTextRequest()
|
||||
request.recognitionLevel = .accurate
|
||||
request.recognitionLanguages = ["pt-BR", "en-US"]
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
guard let texto = observacao as? VNRecognizedTextObservation,
|
||||
let candidato = texto.topCandidates(1).first else { return nil }
|
||||
return Evidencia(tipo: "ocr", valor: ["texto": .texto(candidato.string),
|
||||
"bounding_box": .objeto(caixa(texto.boundingBox))], confianca: Double(candidato.confidence), modelo: "VNRecognizeTextRequest")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("categorias") {
|
||||
let request = VNClassifyImageRequest()
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
guard let categoria = observacao as? VNClassificationObservation, categoria.confidence >= 0.2 else { return nil }
|
||||
return Evidencia(tipo: "categoria", valor: ["identificador": .texto(categoria.identifier)],
|
||||
confianca: Double(categoria.confidence), modelo: "VNClassifyImageRequest")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("estetica") {
|
||||
let request = VNCalculateImageAestheticsScoresRequest()
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
guard let score = observacao as? VNImageAestheticsScoresObservation else { return nil }
|
||||
return Evidencia(tipo: "estetica", valor: ["score_global": .numero(Double(score.overallScore))],
|
||||
confianca: nil, modelo: "VNCalculateImageAestheticsScoresRequest")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("codigos") {
|
||||
let request = VNDetectBarcodesRequest()
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
guard let codigo = observacao as? VNBarcodeObservation else { return nil }
|
||||
return Evidencia(tipo: "codigo", valor: ["payload": .texto(codigo.payloadStringValue ?? ""),
|
||||
"simbologia": .texto(codigo.symbology.rawValue), "bounding_box": .objeto(caixa(codigo.boundingBox))],
|
||||
confianca: Double(codigo.confidence), modelo: "VNDetectBarcodesRequest")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("horizonte") {
|
||||
let request = VNDetectHorizonRequest()
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
guard let horizonte = observacao as? VNHorizonObservation else { return nil }
|
||||
return Evidencia(tipo: "horizonte", valor: ["angulo_radianos": .numero(Double(horizonte.angle))],
|
||||
confianca: Double(horizonte.confidence), modelo: "VNDetectHorizonRequest")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("retangulos") {
|
||||
let request = VNDetectRectanglesRequest()
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
guard let retangulo = observacao as? VNRectangleObservation else { return nil }
|
||||
return Evidencia(tipo: "retangulo", valor: ["bounding_box": .objeto(caixa(retangulo.boundingBox)),
|
||||
"cantos": .objeto(["superior_esquerdo": ponto(retangulo.topLeft),
|
||||
"superior_direito": ponto(retangulo.topRight),
|
||||
"inferior_esquerdo": ponto(retangulo.bottomLeft),
|
||||
"inferior_direito": ponto(retangulo.bottomRight)])],
|
||||
confianca: Double(retangulo.confidence), modelo: "VNDetectRectanglesRequest")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("contornos") {
|
||||
let request = VNDetectContoursRequest()
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
guard let contornos = observacao as? VNContoursObservation else { return nil }
|
||||
return Evidencia(tipo: "contornos", valor: ["quantidade_principal": .numero(Double(contornos.topLevelContours.count))],
|
||||
confianca: Double(contornos.confidence), modelo: "VNDetectContoursRequest")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("segmentacao_de_pessoas") {
|
||||
let request = VNGeneratePersonSegmentationRequest()
|
||||
request.qualityLevel = .balanced
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
guard let mascara = observacao as? VNPixelBufferObservation else { return nil }
|
||||
return Evidencia(tipo: "segmentacao_de_pessoas", valor: ["ocupacao_do_quadro": .numero(coberturaDaMascara(mascara.pixelBuffer))],
|
||||
confianca: nil, modelo: "VNGeneratePersonSegmentationRequest")
|
||||
}
|
||||
}
|
||||
|
||||
return Saida(evidencias: evidencias, avisos: avisos)
|
||||
}
|
||||
|
||||
func featurePrint(_ url: URL) throws -> VNFeaturePrintObservation {
|
||||
let request = VNGenerateImageFeaturePrintRequest()
|
||||
guard let resultado = try executar(request, em: url).first as? VNFeaturePrintObservation else {
|
||||
throw NSError(domain: "JhonnyAppleVision", code: 2, userInfo: [NSLocalizedDescriptionKey: "Vision não produziu feature print"])
|
||||
}
|
||||
return resultado
|
||||
}
|
||||
|
||||
func analisarSequencia(_ entrada: Entrada) -> Saida {
|
||||
let caminhos = entrada.frames ?? []
|
||||
guard caminhos.count >= 2 else { return Saida(evidencias: [], avisos: ["sequencia: informe ao menos dois frames"]) }
|
||||
var evidencias: [Evidencia] = []
|
||||
var avisos: [String] = []
|
||||
for indice in 0..<(caminhos.count - 1) {
|
||||
do {
|
||||
var distancia: Float = 0
|
||||
try featurePrint(URL(fileURLWithPath: caminhos[indice])).computeDistance(
|
||||
&distancia, to: featurePrint(URL(fileURLWithPath: caminhos[indice + 1])))
|
||||
evidencias.append(Evidencia(tipo: "similaridade_visual", valor: [
|
||||
"frame_inicial": .numero(Double(indice)), "frame_final": .numero(Double(indice + 1)),
|
||||
"distancia_feature_print": .numero(Double(distancia)),
|
||||
"possivel_mudanca_de_cena": .booleano(distancia > 12),
|
||||
], confianca: nil, modelo: "VNGenerateImageFeaturePrintRequest"))
|
||||
} catch { avisos.append("similaridade_visual[\(indice)]: \(error.localizedDescription)") }
|
||||
}
|
||||
return Saida(evidencias: evidencias, avisos: avisos)
|
||||
}
|
||||
|
||||
extension Dictionary where Key == String, Value == JSONValue {
|
||||
func numero(_ chave: String) -> Double? {
|
||||
if case .numero(let valor)? = self[chave] { return valor }
|
||||
return nil
|
||||
}
|
||||
func texto(_ chave: String) -> String? {
|
||||
if case .texto(let valor)? = self[chave] { return valor }
|
||||
return nil
|
||||
}
|
||||
func booleano(_ chave: String) -> Bool? {
|
||||
if case .booleano(let valor)? = self[chave] { return valor }
|
||||
return nil
|
||||
}
|
||||
func objeto(_ chave: String) -> [String: JSONValue]? {
|
||||
if case .objeto(let valor)? = self[chave] { return valor }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Traduz uma evidência em uma frase factual com os valores medidos, nunca só o nome do tipo —
|
||||
/// é isso que alimenta a interpretação editorial, então precisa carregar o fato, não só o rótulo.
|
||||
func descreverEvidencia(_ evidencia: Evidencia) -> String {
|
||||
let valor = evidencia.valor
|
||||
switch evidencia.tipo {
|
||||
case "rosto":
|
||||
let grupos = valor.objeto("landmarks")?.count ?? 0
|
||||
return "rosto detectado (\(grupos) grupos de landmarks mapeados)"
|
||||
case "qualidade_do_rosto":
|
||||
guard let score = valor.numero("score") else { return "qualidade do rosto avaliada" }
|
||||
return "qualidade do rosto: score \(String(format: "%.2f", score))"
|
||||
case "pessoa":
|
||||
guard let ocupacao = valor.numero("ocupacao_do_quadro") else { return "pessoa detectada" }
|
||||
return "pessoa ocupando \(String(format: "%.0f", ocupacao * 100))% do quadro"
|
||||
case "pose":
|
||||
let pontos = valor.objeto("pontos")?.count ?? 0
|
||||
return "pose corporal com \(pontos) pontos reconhecidos"
|
||||
case "mao":
|
||||
let pontos = valor.objeto("pontos")?.count ?? 0
|
||||
return "mão com \(pontos) pontos reconhecidos"
|
||||
case "ocr":
|
||||
guard let texto = valor.texto("texto"), !texto.isEmpty else { return "nenhum texto legível na imagem" }
|
||||
return "texto detectado na imagem: \"\(texto)\""
|
||||
case "categoria":
|
||||
guard let identificador = valor.texto("identificador") else { return "categoria detectada" }
|
||||
let confianca = evidencia.confianca.map { String(format: "%.0f%%", $0 * 100) } ?? "confiança desconhecida"
|
||||
return "categoria \"\(identificador)\" (\(confianca))"
|
||||
case "estetica":
|
||||
guard let score = valor.numero("score_global") else { return "score estético calculado" }
|
||||
return "score estético global: \(String(format: "%.2f", score))"
|
||||
case "codigo":
|
||||
guard let payload = valor.texto("payload"), !payload.isEmpty else { return "código detectado sem payload legível" }
|
||||
return "código detectado: \"\(payload)\""
|
||||
case "horizonte":
|
||||
guard let angulo = valor.numero("angulo_radianos") else { return "horizonte detectado" }
|
||||
return "horizonte inclinado \(String(format: "%.1f", angulo * 180 / .pi))°"
|
||||
case "retangulo":
|
||||
guard let bbox = valor.objeto("bounding_box"), let largura = bbox.numero("largura"), let altura = bbox.numero("altura") else {
|
||||
return "retângulo/documento detectado no quadro"
|
||||
}
|
||||
return "retângulo detectado ocupando \(String(format: "%.0f", largura * 100))%x\(String(format: "%.0f", altura * 100))% do quadro"
|
||||
case "contornos":
|
||||
guard let quantidade = valor.numero("quantidade_principal") else { return "contornos detectados" }
|
||||
return "\(Int(quantidade)) contornos principais detectados"
|
||||
case "segmentacao_de_pessoas":
|
||||
guard let ocupacao = valor.numero("ocupacao_do_quadro") else { return "segmentação de pessoa calculada" }
|
||||
return "silhueta de pessoa cobrindo \(String(format: "%.0f", ocupacao * 100))% do quadro"
|
||||
case "similaridade_visual":
|
||||
let mudanca = valor.booleano("possivel_mudanca_de_cena") ?? false
|
||||
return mudanca ? "possível corte de cena entre os quadros" : "quadros visualmente semelhantes, sem corte de cena"
|
||||
default:
|
||||
return evidencia.tipo
|
||||
}
|
||||
}
|
||||
|
||||
func interpretar(_ saida: Saida) async -> String? {
|
||||
guard SystemLanguageModel.default.isAvailable else { return nil }
|
||||
let fatos = saida.evidencias.map(descreverEvidencia).joined(separator: "; ")
|
||||
guard !fatos.isEmpty else { return nil }
|
||||
do {
|
||||
let sessao = LanguageModelSession(instructions: "Você é um assistente editorial. Descreva apenas fatos explicitamente fornecidos; não invente pessoas, emoções, intenção ou identidade. Responda em uma frase curta em português.")
|
||||
return try await sessao.respond(to: "Evidências visuais disponíveis: \(fatos). Gere uma observação editorial conservadora.").content
|
||||
} catch { return nil }
|
||||
}
|
||||
|
||||
@main struct Principal {
|
||||
static func main() async {
|
||||
do {
|
||||
let entrada = try JSONDecoder().decode(Entrada.self, from: FileHandle.standardInput.readDataToEndOfFile())
|
||||
var saida = entrada.frames?.count ?? 0 > 1 ? analisarSequencia(entrada) : analisarVision(entrada)
|
||||
if entrada.resumir, let resumo = await interpretar(saida) {
|
||||
saida = Saida(evidencias: saida.evidencias + [Evidencia(tipo: "interpretacao_editorial", valor: ["texto": .texto(resumo)], confianca: nil, modelo: "AppleFoundationModels")], avisos: saida.avisos)
|
||||
}
|
||||
let dados = try JSONEncoder().encode(saida)
|
||||
FileHandle.standardOutput.write(dados)
|
||||
} catch {
|
||||
FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8))
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from ...dominio import Clipe
|
||||
from ...scanner.modelos import Cena, EvidenciaVisual
|
||||
from .modelos import QuadroDeVideo
|
||||
|
||||
|
||||
class ExtratorDeQuadros(ABC):
|
||||
"""Interface para obter uma amostra temporal de frames de um clipe."""
|
||||
|
||||
@abstractmethod
|
||||
def extrair(self, clipe: Clipe) -> list[QuadroDeVideo]: ...
|
||||
|
||||
|
||||
class AnalisadorDeFrame(ABC):
|
||||
"""Interface comum: recebe um frame e devolve evidências do domínio."""
|
||||
|
||||
nome: str
|
||||
|
||||
@abstractmethod
|
||||
def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]: ...
|
||||
|
||||
|
||||
class AnalisadorDeSequenciaDeQuadros(ABC):
|
||||
"""Interface para evidências que só existem ao comparar vários frames."""
|
||||
|
||||
nome: str
|
||||
|
||||
@abstractmethod
|
||||
def analisar(self, quadros: list[QuadroDeVideo]) -> list[EvidenciaVisual]: ...
|
||||
|
||||
|
||||
class DetectorDeIntervalosDeCena(ABC):
|
||||
"""Interface para algoritmos que encontram intervalos de cena no clipe."""
|
||||
|
||||
nome: str
|
||||
|
||||
@abstractmethod
|
||||
def detectar(self, clipe: Clipe, quadros: list[QuadroDeVideo]) -> list[Cena]: ...
|
||||
@@ -0,0 +1,34 @@
|
||||
from typing import Any
|
||||
|
||||
from ...dominio import Clipe
|
||||
from ...scanner.modelos import Cena
|
||||
from .contratos import DetectorDeIntervalosDeCena
|
||||
from .modelos import QuadroDeVideo
|
||||
|
||||
|
||||
class DetectorDeCenasPySceneDetect(DetectorDeIntervalosDeCena):
|
||||
"""Adapter PySceneDetect que devolve somente cenas do domínio."""
|
||||
|
||||
nome = "pyscenedetect"
|
||||
|
||||
def __init__(self, limiar: float = 27.0, detector: Any | None = None,
|
||||
detectar_funcao: Any | None = None) -> None:
|
||||
self.limiar = limiar
|
||||
self._detector = detector
|
||||
self._detectar_funcao = detectar_funcao
|
||||
|
||||
def detectar(self, clipe: Clipe, quadros: list[QuadroDeVideo]) -> list[Cena]:
|
||||
if not clipe.arquivo:
|
||||
raise ValueError("Clipe não possui arquivo de origem.")
|
||||
if self._detectar_funcao is None:
|
||||
try:
|
||||
from scenedetect import ContentDetector, detect
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("PySceneDetect não está instalado. Instale scenedetect.") from exc
|
||||
detector = self._detector or ContentDetector(threshold=self.limiar)
|
||||
detectar = lambda arquivo: detect(arquivo, detector)
|
||||
else:
|
||||
detectar = self._detectar_funcao
|
||||
resultado = detectar(str(clipe.arquivo))
|
||||
return [Cena(float(inicio.get_seconds()), float(fim.get_seconds()), referencias=(float(inicio.get_seconds()),))
|
||||
for inicio, fim in resultado]
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Extrator de frames para Vision usando somente ferramentas locais do sistema."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
from ...dominio import Clipe
|
||||
from .contratos import ExtratorDeQuadros
|
||||
from .extrator_open_cv import ExtratorDeQuadrosOpenCV
|
||||
from .modelos import QuadroDeVideo
|
||||
|
||||
|
||||
class ExtratorDeQuadrosFFmpeg(ExtratorDeQuadros):
|
||||
"""Persiste amostras de um arquivo original sem carregar OpenCV no processo."""
|
||||
|
||||
def __init__(self, intervalo_em_segundos: float = 1.0,
|
||||
diretorio_de_cache: str | Path = ".frames",
|
||||
maximo_de_quadros: int | None = None,
|
||||
ffmpeg: str = "ffmpeg", ffprobe: str = "ffprobe") -> None:
|
||||
if intervalo_em_segundos <= 0:
|
||||
raise ValueError("intervalo_em_segundos deve ser positivo.")
|
||||
if maximo_de_quadros is not None and maximo_de_quadros < 1:
|
||||
raise ValueError("maximo_de_quadros deve ser maior que zero.")
|
||||
self.intervalo_em_segundos = intervalo_em_segundos
|
||||
self.diretorio_de_cache = Path(diretorio_de_cache)
|
||||
self.maximo_de_quadros = maximo_de_quadros
|
||||
self.ffmpeg = ffmpeg
|
||||
self.ffprobe = ffprobe
|
||||
|
||||
def extrair(self, clipe: Clipe) -> list[QuadroDeVideo]:
|
||||
if not clipe.arquivo:
|
||||
raise ValueError("Clipe não possui arquivo de origem.")
|
||||
arquivo = Path(clipe.arquivo)
|
||||
if not arquivo.is_file():
|
||||
raise FileNotFoundError(f"Arquivo do clipe não encontrado: {arquivo}")
|
||||
largura, altura, duracao = self._metadados(arquivo)
|
||||
inicio, fim = ExtratorDeQuadrosOpenCV._intervalo_de_origem(clipe, duracao)
|
||||
timestamps = ExtratorDeQuadrosOpenCV(self.intervalo_em_segundos,
|
||||
maximo_de_quadros=self.maximo_de_quadros)._timestamps(inicio, fim)
|
||||
resultado: list[QuadroDeVideo] = []
|
||||
for indice, timestamp in enumerate(timestamps):
|
||||
# PNG evita a recodificação JPEG de YUV limitado, que falha em parte
|
||||
# dos vídeos móveis e ainda preserva melhor OCR/segmentação para Vision.
|
||||
destino = self.diretorio_de_cache / arquivo.stem / f"frame-{indice:06d}.png"
|
||||
destino.parent.mkdir(parents=True, exist_ok=True)
|
||||
amostrado, processo = self._extrair_frame(arquivo, destino, timestamp, inicio)
|
||||
if processo.returncode != 0 or not destino.is_file():
|
||||
detalhe = processo.stderr.strip() or "ffmpeg não produziu o frame"
|
||||
raise RuntimeError(f"Não foi possível extrair frame em {timestamp}s: {detalhe}")
|
||||
resultado.append(QuadroDeVideo(amostrado, indice, largura, altura, None, destino))
|
||||
return resultado
|
||||
|
||||
def _extrair_frame(self, arquivo: Path, destino: Path, timestamp: float,
|
||||
inicio: float) -> tuple[float, subprocess.CompletedProcess[str]]:
|
||||
"""Recua um pouco no fim do arquivo se o decoder não encontrar frame no timestamp."""
|
||||
ultimo: subprocess.CompletedProcess[str] | None = None
|
||||
for candidato in (timestamp, max(inicio, timestamp - 0.1)):
|
||||
if destino.exists():
|
||||
destino.unlink()
|
||||
processo = subprocess.run([self.ffmpeg, "-hide_banner", "-loglevel", "error", "-ss", str(candidato),
|
||||
"-i", str(arquivo), "-frames:v", "1", "-y", str(destino)],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
if processo.returncode == 0 and destino.is_file():
|
||||
return candidato, processo
|
||||
ultimo = processo
|
||||
assert ultimo is not None
|
||||
return timestamp, ultimo
|
||||
|
||||
def _metadados(self, arquivo: Path) -> tuple[int, int, float]:
|
||||
processo = subprocess.run([self.ffprobe, "-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=width,height:format=duration",
|
||||
"-of", "json", str(arquivo)], capture_output=True, text=True, timeout=30)
|
||||
if processo.returncode != 0:
|
||||
raise RuntimeError(processo.stderr.strip() or "ffprobe não conseguiu ler o vídeo")
|
||||
dados = json.loads(processo.stdout)
|
||||
stream = (dados.get("streams") or [{}])[0]
|
||||
return int(stream["width"]), int(stream["height"]), float((dados.get("format") or {}).get("duration") or 0)
|
||||
@@ -0,0 +1,87 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ...dominio import Clipe
|
||||
from .contratos import ExtratorDeQuadros
|
||||
from .modelos import QuadroDeVideo
|
||||
|
||||
|
||||
class ExtratorDeQuadrosOpenCV(ExtratorDeQuadros):
|
||||
"""Extrai frames em intervalos regulares sem expor detalhes do OpenCV."""
|
||||
|
||||
def __init__(self, intervalo_em_segundos: float = 1.0,
|
||||
diretorio_de_cache: str | Path | None = None,
|
||||
maximo_de_quadros: int | None = None,
|
||||
cv2_module: Any | None = None) -> None:
|
||||
if intervalo_em_segundos <= 0:
|
||||
raise ValueError("intervalo_em_segundos deve ser positivo.")
|
||||
if maximo_de_quadros is not None and maximo_de_quadros < 1:
|
||||
raise ValueError("maximo_de_quadros deve ser maior que zero.")
|
||||
self.intervalo_em_segundos = intervalo_em_segundos
|
||||
self.diretorio_de_cache = Path(diretorio_de_cache) if diretorio_de_cache else None
|
||||
self.maximo_de_quadros = maximo_de_quadros
|
||||
self._cv2 = cv2_module
|
||||
|
||||
@property
|
||||
def cv2(self) -> Any:
|
||||
if self._cv2 is None:
|
||||
try:
|
||||
import cv2
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("OpenCV não está instalado. Instale opencv-python.") from exc
|
||||
self._cv2 = cv2
|
||||
return self._cv2
|
||||
|
||||
def extrair(self, clipe: Clipe) -> list[QuadroDeVideo]:
|
||||
if not clipe.arquivo:
|
||||
raise ValueError("Clipe não possui arquivo de origem.")
|
||||
caminho = Path(clipe.arquivo)
|
||||
if not caminho.is_file():
|
||||
raise FileNotFoundError(f"Arquivo do clipe não encontrado: {caminho}")
|
||||
captura = self.cv2.VideoCapture(str(caminho))
|
||||
if not captura.isOpened():
|
||||
raise RuntimeError(f"OpenCV não conseguiu abrir o vídeo: {caminho}")
|
||||
try:
|
||||
fps = float(captura.get(self.cv2.CAP_PROP_FPS) or 0)
|
||||
total_de_frames = int(captura.get(self.cv2.CAP_PROP_FRAME_COUNT) or 0)
|
||||
duracao = total_de_frames / fps if fps > 0 else 0.0
|
||||
inicio, fim = self._intervalo_de_origem(clipe, duracao)
|
||||
timestamps = self._timestamps(inicio, fim)
|
||||
resultado: list[QuadroDeVideo] = []
|
||||
for indice, timestamp in enumerate(timestamps):
|
||||
captura.set(self.cv2.CAP_PROP_POS_MSEC, timestamp * 1000.0)
|
||||
sucesso, imagem = captura.read()
|
||||
if not sucesso or imagem is None:
|
||||
continue
|
||||
altura, largura = imagem.shape[:2]
|
||||
salvo = self._salvar(caminho, indice, imagem)
|
||||
resultado.append(QuadroDeVideo(timestamp, indice, largura, altura, imagem, salvo))
|
||||
return resultado
|
||||
finally:
|
||||
captura.release()
|
||||
|
||||
def _timestamps(self, inicio: float, fim: float) -> list[float]:
|
||||
if fim <= inicio:
|
||||
return [inicio]
|
||||
quantidade = int((fim - inicio) / self.intervalo_em_segundos) + 1
|
||||
limite = max(inicio, fim - 0.001)
|
||||
timestamps = [min(inicio + indice * self.intervalo_em_segundos, limite)
|
||||
for indice in range(quantidade)]
|
||||
return timestamps[:self.maximo_de_quadros]
|
||||
|
||||
@staticmethod
|
||||
def _intervalo_de_origem(clipe: Clipe, duracao: float) -> tuple[float, float]:
|
||||
if clipe.intervalo_na_origem is None:
|
||||
return 0.0, duracao
|
||||
inicio = max(0.0, clipe.intervalo_na_origem.inicio)
|
||||
fim = min(duracao, clipe.intervalo_na_origem.fim) if duracao > 0 else clipe.intervalo_na_origem.fim
|
||||
return inicio, max(inicio, fim)
|
||||
|
||||
def _salvar(self, arquivo: Path, indice: int, imagem: Any) -> Path | None:
|
||||
if self.diretorio_de_cache is None:
|
||||
return None
|
||||
destino = self.diretorio_de_cache / arquivo.stem / f"frame-{indice:06d}.jpg"
|
||||
destino.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not self.cv2.imwrite(str(destino), imagem):
|
||||
raise RuntimeError(f"Não foi possível salvar frame: {destino}")
|
||||
return destino
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Executável interno isolado para evitar que falhas nativas do MediaPipe derrubem o Scanner."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _pontos(landmarks, visibilidade: bool = False):
|
||||
return [{"x": ponto.x, "y": ponto.y, "z": ponto.z,
|
||||
**({"visibilidade": getattr(ponto, "visibility", None)} if visibilidade else {})}
|
||||
for ponto in landmarks]
|
||||
|
||||
|
||||
def executar(carga: dict) -> list[dict]:
|
||||
import cv2
|
||||
import mediapipe as mp
|
||||
|
||||
imagem_bgr = cv2.imread(carga["frame"])
|
||||
if imagem_bgr is None:
|
||||
raise FileNotFoundError(f"Frame não encontrado: {carga['frame']}")
|
||||
imagem = mp.Image(image_format=mp.ImageFormat.SRGB, data=imagem_bgr[:, :, ::-1])
|
||||
resultado: list[dict] = []
|
||||
if carga.get("pose"):
|
||||
opcoes = mp.tasks.vision.PoseLandmarkerOptions(
|
||||
base_options=mp.tasks.BaseOptions(model_asset_path=carga["pose"], delegate=mp.tasks.BaseOptions.Delegate.CPU),
|
||||
running_mode=mp.tasks.vision.RunningMode.IMAGE,
|
||||
)
|
||||
with mp.tasks.vision.PoseLandmarker.create_from_options(opcoes) as detector:
|
||||
for pose in detector.detect(imagem).pose_landmarks:
|
||||
resultado.append({"tipo": "pose", "valor": {"pontos": _pontos(pose, visibilidade=True)}})
|
||||
if carga.get("maos"):
|
||||
opcoes = mp.tasks.vision.HandLandmarkerOptions(
|
||||
base_options=mp.tasks.BaseOptions(model_asset_path=carga["maos"], delegate=mp.tasks.BaseOptions.Delegate.CPU),
|
||||
running_mode=mp.tasks.vision.RunningMode.IMAGE, num_hands=4,
|
||||
)
|
||||
with mp.tasks.vision.HandLandmarker.create_from_options(opcoes) as detector:
|
||||
for mao in detector.detect(imagem).hand_landmarks:
|
||||
resultado.append({"tipo": "mao", "valor": {"pontos": _pontos(mao)}})
|
||||
return resultado
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
print(json.dumps(executar(json.loads(sys.stdin.read()))))
|
||||
except Exception as erro:
|
||||
print(str(erro), file=sys.stderr)
|
||||
raise
|
||||
@@ -0,0 +1,15 @@
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuadroDeVideo:
|
||||
"""Frame extraído, com timestamp relativo ao arquivo de origem."""
|
||||
|
||||
timestamp: float
|
||||
indice: int
|
||||
largura: int
|
||||
altura: int
|
||||
imagem: Any
|
||||
caminho: Path | None = None
|
||||
@@ -1,7 +1,7 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ...scanner.modelos import SegmentoDeTranscricao
|
||||
from ...scanner.modelos import PalavraDeTranscricao, SegmentoDeTranscricao
|
||||
|
||||
|
||||
class ProviderDeTranscricaoLocal:
|
||||
@@ -9,6 +9,9 @@ class ProviderDeTranscricaoLocal:
|
||||
|
||||
def __init__(self, modelo: str, dispositivo: str = "cpu", tipo_de_calculo: str = "int8",
|
||||
idioma: str = "pt") -> None:
|
||||
self.nome_do_modelo = Path(modelo).name
|
||||
if "faster-whisper-" in modelo:
|
||||
self.nome_do_modelo = modelo.split("faster-whisper-", 1)[1].split("/", 1)[0]
|
||||
from faster_whisper import WhisperModel
|
||||
self.modelo = WhisperModel(modelo, device=dispositivo, compute_type=tipo_de_calculo)
|
||||
self.idioma = idioma
|
||||
@@ -17,5 +20,9 @@ class ProviderDeTranscricaoLocal:
|
||||
arquivo = Path(clipe.arquivo)
|
||||
if not arquivo.is_file():
|
||||
raise FileNotFoundError(f"Arquivo de áudio não encontrado: {arquivo}")
|
||||
segmentos, _ = self.modelo.transcribe(str(arquivo), language=self.idioma, vad_filter=True)
|
||||
return [SegmentoDeTranscricao(float(s.start), float(s.end), s.text.strip()) for s in segmentos]
|
||||
segmentos, _ = self.modelo.transcribe(str(arquivo), language=self.idioma,
|
||||
vad_filter=True, word_timestamps=True)
|
||||
return [SegmentoDeTranscricao(float(s.start), float(s.end), s.text.strip(),
|
||||
None, tuple(PalavraDeTranscricao(w.word.strip(), float(w.start), float(w.end),
|
||||
getattr(w, "probability", None))
|
||||
for w in (s.words or []) if w.word.strip())) for s in segmentos]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Dependências opcionais para os adapters locais de análise visual.
|
||||
opencv-python
|
||||
scenedetect
|
||||
onnxruntime
|
||||
mediapipe
|
||||
# Apenas macOS; permite usar Vision diretamente a partir do Python.
|
||||
pyobjc-framework-Vision
|
||||
@@ -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 [],
|
||||
)
|
||||
@@ -0,0 +1,216 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
|
||||
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
|
||||
from engine.scanner.relatorio_visual import gerar_relatorio_visual, gerar_resumo_visual_markdown
|
||||
from engine.scanner.configuracao_visual import ConfiguracaoVisualDoScanner
|
||||
|
||||
|
||||
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])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,86 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline
|
||||
from engine.scanner.coordenacao import ContextoDeAnalise
|
||||
from engine.scanner.descoberta import ArquivoDescoberto, DescobertaDeArquivos
|
||||
from engine.scanner.metadados import ExtracaoDeMetadados
|
||||
from engine.integracoes.midia.extracao_de_metadados import ExtracaoDeMetadados as Adapter
|
||||
|
||||
|
||||
class TesteArquivosEMetadados(unittest.TestCase):
|
||||
def _contexto(self, *arquivos: str | None) -> ContextoDeAnalise:
|
||||
clipes = [Clipe(str(i), f"clipe-{i}", IntervaloDeTempo(0, 1), arquivo=arquivo)
|
||||
for i, arquivo in enumerate(arquivos)]
|
||||
timeline = Timeline("timeline", "Principal", faixas=[Faixa("v1", "V1", "video", 0, clipes)])
|
||||
return ContextoDeAnalise(timeline=timeline)
|
||||
|
||||
def test_descoberta_normaliza_caminho_e_marca_offline(self):
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
arquivo = Path(pasta) / "camera.mp4"
|
||||
arquivo.touch()
|
||||
contexto = self._contexto(str(arquivo), str(Path(pasta) / "ausente.mp4"), None)
|
||||
|
||||
DescobertaDeArquivos().executar(contexto)
|
||||
|
||||
self.assertEqual(contexto.timeline.faixas[0].clipes[0].arquivo, str(arquivo.resolve()))
|
||||
self.assertFalse(contexto.timeline.faixas[0].clipes[0].offline)
|
||||
self.assertTrue(contexto.timeline.faixas[0].clipes[1].offline)
|
||||
self.assertTrue(contexto.timeline.faixas[0].clipes[2].offline)
|
||||
self.assertEqual(len(contexto.erros), 2)
|
||||
|
||||
def test_descoberta_identifica_arquivo_duplicado(self):
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
arquivo = Path(pasta) / "camera.mov"
|
||||
arquivo.touch()
|
||||
contexto = self._contexto(str(arquivo), str(arquivo))
|
||||
|
||||
DescobertaDeArquivos().executar(contexto)
|
||||
|
||||
self.assertEqual(len(contexto.avisos), 1)
|
||||
|
||||
def test_extracao_converte_ffprobe_e_usa_cache(self):
|
||||
dados = {"format": {"format_name": "mov,mp4,m4a", "duration": "12.5", "size": "900"},
|
||||
"streams": [{"codec_type": "video", "codec_name": "h264", "width": 1920,
|
||||
"height": 1080, "avg_frame_rate": "30000/1001"},
|
||||
{"codec_type": "audio", "codec_name": "aac", "channels": 2,
|
||||
"sample_rate": "48000"}]}
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
arquivo = Path(pasta) / "camera.mp4"
|
||||
arquivo.touch()
|
||||
adapter = Adapter(ffprobe="ffprobe-simulado")
|
||||
processo = type("Processo", (), {"stdout": json.dumps(dados)})()
|
||||
with patch("engine.integracoes.midia.extracao_de_metadados.subprocess.run", return_value=processo) as run:
|
||||
primeiro = adapter.extrair(arquivo)
|
||||
segundo = adapter.extrair(arquivo)
|
||||
|
||||
self.assertEqual(primeiro.codec_de_video, "h264")
|
||||
self.assertEqual(primeiro.codec_de_audio, "aac")
|
||||
self.assertEqual((primeiro.largura, primeiro.altura), (1920, 1080))
|
||||
self.assertAlmostEqual(primeiro.taxa_de_quadros, 29.97002997, places=5)
|
||||
self.assertEqual(primeiro.duracao, 12.5)
|
||||
self.assertIs(primeiro, segundo)
|
||||
run.assert_called_once()
|
||||
|
||||
def test_etapa_de_metadados_ignora_arquivos_offline(self):
|
||||
class ExtratorSimulado:
|
||||
def extrair(self, arquivo):
|
||||
return "metadados"
|
||||
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
arquivo = Path(pasta) / "camera.mp4"
|
||||
arquivo.touch()
|
||||
contexto = self._contexto(str(arquivo), str(Path(pasta) / "ausente.mp4"))
|
||||
DescobertaDeArquivos().executar(contexto)
|
||||
|
||||
ExtracaoDeMetadados(ExtratorSimulado()).executar(contexto)
|
||||
|
||||
self.assertEqual(contexto.metadados_dos_arquivos["0"], "metadados")
|
||||
self.assertNotIn("1", contexto.metadados_dos_arquivos)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,32 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from engine.integracoes.apple_speech import ProviderDeTranscricaoApple
|
||||
|
||||
|
||||
class TesteProviderDeTranscricaoApple(unittest.TestCase):
|
||||
@patch("engine.integracoes.apple_speech.provider_de_transcricao_apple.subprocess.run")
|
||||
def test_transcreve_em_pt_br_no_dispositivo_e_preserva_timestamps(self, executar):
|
||||
executar.return_value = type("Resultado", (), {
|
||||
"stdout": json.dumps({"segmentos": [
|
||||
{"inicio": 1.25, "fim": 2.75, "texto": " Olá mundo ", "confianca": 0.93},
|
||||
]}),
|
||||
})()
|
||||
|
||||
with tempfile.TemporaryDirectory() as pasta:
|
||||
arquivo = Path(pasta) / "video.mp4"
|
||||
arquivo.write_bytes(b"video")
|
||||
clipe = type("Clipe", (), {"arquivo": str(arquivo)})()
|
||||
resultado = ProviderDeTranscricaoApple(executavel="transcritor").transcrever(clipe)
|
||||
|
||||
self.assertEqual(resultado[0].texto, "Olá mundo")
|
||||
self.assertEqual((resultado[0].inicio, resultado[0].fim), (1.25, 2.75))
|
||||
comando = executar.call_args.args[0]
|
||||
self.assertEqual(comando, ["transcritor", str(arquivo), "pt-BR", "true"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user