Files
jhonny-editor/code/engine/integracoes/visual/apple_vision.py
T
João Henrique 400c3c93dd feat: destacado no deploy o resumo que será gravado na mensagem do
- 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
2026-09-08 18:40:52 -04:00

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