"""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) -> 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