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:
João Henrique
2026-09-08 16:13:27 -04:00
parent b541f502ba
commit b9bf3b2863
76 changed files with 16147 additions and 322 deletions
@@ -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}
+2 -1
View File
@@ -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
+161
View File
@@ -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
+15
View File
@@ -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]