feat: criado repositório jhonny-editor no Gitea
criado repositório jhonny-editor no Gitea adicionado script admin/deploy.command com commit automático atualizado admin/DEV-NOTES.md com template limpo Resumo: - 48 arquivos alterados - 26 novos - 19 modificados - 3 removidos 22 files changed, 658 insertions(+), 568 deletions(-) Arquivos: - AGENTS.md - admin/DEV-NOTES.md - admin/OpenCut.command - admin/commit.command - admin/deploy.command - admin/update.command - code/cep-plugin/index.html - code/cep-plugin/main.js - code/cep-plugin/styles.css - code/engine/ARQUITETURA.md - code/engine/arquitetura/README.md - code/engine/gerar_relatorio_timeline.py - code/engine/integracoes/apple_speech/apple_speech_transcriber.swift - code/engine/integracoes/apple_speech/provider_de_transcricao_apple.py - code/engine/integracoes/midia/__init__.py - code/engine/integracoes/whisper/provider_de_transcricao_local.py - code/engine/scanner/__init__.py - code/engine/scanner/coordenacao/__init__.py - code/engine/scanner/descoberta/__init__.py - code/engine/scanner/modelos.py - code/engine/scanner/transcricao_da_timeline.py - code/src/tools/discovery.ts - :memory:.ses - admin/Jhonny.command - admin/inativos/OpenCut.command - admin/inativos/update.command - code/docs/glossario-analise-emocional.md - code/docs/levantamento-apple-vision-e-apple-intelligence.md - code/docs/levantamento-ferramentas-analise-visual-local.md - code/engine/arquitetura/biblioteca-inteligente-de-videos.md - code/engine/executar_scanner.py - code/engine/integracoes/huggingface/ - code/engine/integracoes/midia/extracao_de_metadados.py - code/engine/integracoes/visual/ - code/engine/requirements-visual.txt - code/engine/scanner/configuracao_visual.py - code/engine/scanner/descoberta/descoberta_de_arquivos.py - code/engine/scanner/metadados.py - code/engine/scanner/relatorio_visual.py - code/engine/scanner/retakes/ - code/engine/scanner/visual.py - code/engine/testes/test_analise_visual_local.py - code/engine/testes/test_arquivos_e_metadados.py - code/engine/testes/test_provider_de_transcricao_apple.py - code/relatorios/analise-brools/ - code/relatorios/analise-visual/ - code/relatorios/audio/arquivos/ - code/relatorios/transcricao-timeline.md
This commit is contained in:
@@ -1,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]
|
||||
|
||||
Reference in New Issue
Block a user