- destacado no deploy o resumo que será gravado na mensagem do commit Resumo: - 8 arquivos alterados - 1 novos - 7 modificados - 0 removidos 7 files changed, 143 insertions(+), 11 deletions(-) Arquivos: - .jhonny/analises.db - admin/deploy.command - code/engine/executar_scanner.py - code/engine/integracoes/midia/__init__.py - code/engine/integracoes/visual/apple_vision.py - code/engine/scanner/configuracao_visual.py - code/engine/scanner/visual.py - code/engine/integracoes/midia/compactador_de_proxy.py
233 lines
11 KiB
Python
233 lines
11 KiB
Python
"""Adapter Python para o runner Swift que concentra Vision e Apple Intelligence."""
|
|
|
|
import atexit
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import queue
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
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 _TrabalhadorPersistente:
|
|
"""Um processo do runner Swift vivo entre chamadas, evitando recarregar Vision/Foundation Models a cada frame."""
|
|
|
|
def __init__(self, executavel: Path) -> None:
|
|
self.processo = subprocess.Popen([str(executavel)], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE, text=True, bufsize=1)
|
|
|
|
def executar(self, carga: dict[str, Any], timeout: float) -> dict[str, Any]:
|
|
if self.processo.poll() is not None or not self.processo.stdin or not self.processo.stdout:
|
|
raise RuntimeError("Runner Apple Vision não está mais em execução.")
|
|
try:
|
|
self.processo.stdin.write(json.dumps(carga) + "\n")
|
|
self.processo.stdin.flush()
|
|
except (BrokenPipeError, ValueError) as exc:
|
|
raise RuntimeError("Runner Apple Vision não aceitou o pedido.") from exc
|
|
|
|
resposta: dict[str, str] = {}
|
|
falha: list[BaseException] = []
|
|
|
|
def ler() -> None:
|
|
try:
|
|
resposta["linha"] = self.processo.stdout.readline() # type: ignore[union-attr]
|
|
except BaseException as exc: # noqa: BLE001 - repassado à thread principal
|
|
falha.append(exc)
|
|
|
|
leitor = threading.Thread(target=ler, daemon=True)
|
|
leitor.start()
|
|
leitor.join(timeout)
|
|
if leitor.is_alive():
|
|
self.encerrar()
|
|
raise TimeoutError(f"Runner Apple Vision excedeu {timeout}s.")
|
|
if falha:
|
|
raise falha[0]
|
|
linha = resposta.get("linha", "")
|
|
if not linha:
|
|
detalhe = (self.processo.stderr.read() if self.processo.stderr else "") or "runner encerrou sem resposta"
|
|
raise RuntimeError(f"Runner Apple Vision falhou: {detalhe.strip()}")
|
|
return json.loads(linha)
|
|
|
|
def encerrar(self) -> None:
|
|
if self.processo.poll() is None:
|
|
self.processo.kill()
|
|
try:
|
|
self.processo.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
|
|
|
|
class ExecutorDoRunnerApple:
|
|
"""Compila o runner Swift quando necessário e mantém um pool de processos persistentes."""
|
|
|
|
def __init__(self, fonte: str | Path | None = None, compilador: str = "swiftc",
|
|
diretorio_de_build: str | Path | None = None, trabalhadores: int = 4) -> 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")
|
|
self.trabalhadores = max(1, trabalhadores)
|
|
self._pool: "queue.Queue[_TrabalhadorPersistente]" | None = None
|
|
self._trava = threading.Lock()
|
|
self._todos: list[_TrabalhadorPersistente] = []
|
|
atexit.register(self.encerrar)
|
|
|
|
def executar(self, carga: dict[str, Any], timeout: float) -> dict[str, Any]:
|
|
executavel = self._garantir_compilado()
|
|
pool = self._garantir_pool(executavel)
|
|
trabalhador = pool.get()
|
|
try:
|
|
return trabalhador.executar(carga, timeout)
|
|
except Exception:
|
|
trabalhador.encerrar()
|
|
with self._trava:
|
|
self._todos.remove(trabalhador)
|
|
trabalhador = _TrabalhadorPersistente(executavel)
|
|
self._todos.append(trabalhador)
|
|
raise
|
|
finally:
|
|
pool.put(trabalhador)
|
|
|
|
def encerrar(self) -> None:
|
|
with self._trava:
|
|
for trabalhador in self._todos:
|
|
trabalhador.encerrar()
|
|
self._todos.clear()
|
|
self._pool = None
|
|
|
|
def _garantir_pool(self, executavel: Path) -> "queue.Queue[_TrabalhadorPersistente]":
|
|
with self._trava:
|
|
if self._pool is None:
|
|
self._pool = queue.Queue()
|
|
for _ in range(self.trabalhadores):
|
|
trabalhador = _TrabalhadorPersistente(executavel)
|
|
self._todos.append(trabalhador)
|
|
self._pool.put(trabalhador)
|
|
return self._pool
|
|
|
|
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,
|
|
maximo_de_frames_interpretados: int | None = None) -> 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
|
|
# Limita quantos frames por clipe fazem a interpretação editorial (Apple
|
|
# Intelligence / Foundation Models), que é a parte mais cara. Os demais
|
|
# frames continuam com a análise visual completa. None = interpreta todos.
|
|
self.maximo_de_frames_interpretados = maximo_de_frames_interpretados
|
|
|
|
def analisar(self, quadro: QuadroDeVideo, interpretar: bool | None = None) -> list[EvidenciaVisual]:
|
|
if quadro.caminho is None:
|
|
raise ValueError("Apple Vision requer que o frame tenha caminho persistido em disco.")
|
|
if self._indices_para_interpretar and quadro.indice not in self._indices_para_interpretar:
|
|
interpretar = False
|
|
deve_interpretar = interpretar if interpretar is not None else self.interpretar_com_apple_intelligence
|
|
dados = self.executor.executar({"frame": str(quadro.caminho), "recursos": list(self.recursos),
|
|
"resumir": deve_interpretar},
|
|
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 preparar_interpretacao_por_clipe(self, quantidade_de_frames: int) -> None:
|
|
"""Marca os índices de frames que receberão a interpretação editorial.
|
|
|
|
Distribui a interpretação ao longo do clipe (início, pontos médios e fim)
|
|
em vez de aplicar a todos os frames, mantendo a cobertura de cena com
|
|
custo muito menor. Quando não há limite (None), interpreta todos.
|
|
"""
|
|
if self.maximo_de_frames_interpretados is None or quantidade_de_frames <= 0:
|
|
self._indices_para_interpretar = set(range(quantidade_de_frames))
|
|
return
|
|
quantidade = min(quantidade_de_frames, max(1, self.maximo_de_frames_interpretados))
|
|
indices = set()
|
|
if quantidade_de_frames == 1:
|
|
indices.add(0)
|
|
else:
|
|
passo = max(1, quantidade_de_frames // quantidade)
|
|
indices = set(range(0, quantidade_de_frames, passo)) | {quantidade_de_frames - 1}
|
|
self._indices_para_interpretar = {i for i in indices if i < quantidade_de_frames}
|
|
|
|
_indices_para_interpretar: set[int] = set()
|
|
|
|
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
|