Files
jhonny-editor/code/engine/integracoes/visual/apple_vision_runner.swift
T
João Henrique c4c942044f feat: adicionadas métricas opcionais de fala ao Scanner com anális
- adicionadas métricas opcionais de fala ao Scanner com análise acústica na engine
- corrigido o progresso da transcrição do Scanner durante o processamento do Whisper
- refatorado o runner Swift do Apple Vision para executar requests em lote com retry CPU-only, corrigindo falhas de CVPixelBuffer/ANE/OCR
- documentado o fluxo de edição de vídeo por voz integrado ao Scanner, banco e engine
- formalizado o protocolo de edição por chat com perfil editorial e consultas progressivas

Resumo:
- 10 arquivos alterados
- 5 novos
- 5 modificados
- 0 removidos

 5 files changed, 367 insertions(+), 95 deletions(-)

Arquivos:
  - .jhonny/analises.db
  - code/engine/executar_scanner.py
  - code/engine/integracoes/visual/apple_vision_runner.swift
  - code/engine/integracoes/whisper/provider_de_transcricao_local.py
  - code/engine/testes/test_provider_de_transcricao_local.py
  - .jhonny/analises.db-shm
  - .jhonny/analises.db-wal
  - apple_vision_runner
  - code/relatorios/teste-enquadramento-20s.json
  - docs/
2026-09-09 18:50:00 -04:00

602 lines
28 KiB
Swift

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 carregarImagem(_ url: URL) throws -> CGImage {
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"])
}
return imagem
}
func executar<T: VNRequest>(_ request: T, em url: URL) throws -> [VNObservation] {
let imagem = try carregarImagem(url)
let handler = VNImageRequestHandler(cgImage: imagem, options: [:])
try handler.perform([request])
return request.results ?? []
}
/// Executa múltiplos requests em lote via um único handler, reutilizando a imagem e o CVPixelBuffer interno.
/// Retorna (resultados, erros_por_indice) para que o chamador possa decidir quais requests retryar.
func executarLote(_ requests: [VNRequest], em url: URL) -> ([[VNObservation]], [Int: Error]) {
guard let imagem = try? carregarImagem(url) else {
let erro = NSError(domain: "JhonnyAppleVision", code: 1,
userInfo: [NSLocalizedDescriptionKey: "ImageIO não conseguiu decodificar o frame"])
let todosErros = Dictionary(uniqueKeysWithValues: requests.indices.map { ($0, erro) })
return (Array(repeating: [], count: requests.count), todosErros)
}
let handler = VNImageRequestHandler(cgImage: imagem, options: [:])
do {
try handler.perform(requests)
} catch {
let todosErros = Dictionary(uniqueKeysWithValues: requests.indices.map { ($0, error) })
return (Array(repeating: [], count: requests.count), todosErros)
}
var resultados: [[VNObservation]] = []
var erros: [Int: Error] = [:]
for (indice, request) in requests.enumerated() {
if let resultadosReais = request.results, !resultadosReais.isEmpty {
resultados.append(resultadosReais)
} else {
resultados.append([])
erros[indice] = NSError(domain: "JhonnyAppleVision", code: 3,
userInfo: [NSLocalizedDescriptionKey: "\(type(of: request)) não produziu resultados"])
}
}
return (resultados, erros)
}
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))
}
/// Analisa brilho, contraste e exposição de um CGImage via histograma de luminosidade.
/// Não usa Vision — é puro CoreImage, custo ~10ms.
func analisarBrilho(_ imagem: CGImage) -> [String: JSONValue] {
let largura = imagem.width
let altura = imagem.height
let totalPixels = Double(largura * altura)
// Converte para grayscale lendo os bytes diretamente
guard let contexto = CGContext(data: nil, width: largura, height: altura,
bitsPerComponent: 8, bytesPerRow: largura,
space: CGColorSpaceCreateDeviceGray(),
bitmapInfo: CGImageAlphaInfo.none.rawValue),
let dados = contexto.data else {
return ["media": .numero(0), "contraste": .numero(0), "exposicao": .texto("indisponivel")]
}
contexto.draw(imagem, in: CGRect(x: 0, y: 0, width: largura, height: altura))
let bytes = dados.assumingMemoryBound(to: UInt8.self)
// Calcula média de luminosidade
var soma: Double = 0
var somaQuadrados: Double = 0
var histograma = [Int](repeating: 0, count: 256)
for i in 0..<(largura * altura) {
let pixel = Double(bytes[i])
soma += pixel
somaQuadrados += pixel * pixel
histograma[Int(pixel)] += 1
}
let media = soma / totalPixels / 255.0 // normalizado 0-1
let variancia = (somaQuadrados / totalPixels) - (soma / totalPixels) * (soma / totalPixels)
let contraste = sqrt(variancia) / 255.0 // desvio padrão normalizado
// Classifica exposição
let exposicao: String
if media < 0.25 {
exposicao = "subexposto"
} else if media < 0.40 {
exposicao = "levemente_subexposto"
} else if media <= 0.65 {
exposicao = "equilibrado"
} else if media <= 0.80 {
exposicao = "levemente_sobreexposto"
} else {
exposicao = "sobreexposto"
}
// Detecta clipping (pixels no limite máximo = informação perdida)
let pixelsBrancos = Double(histograma[255] + histograma[254] + histograma[253])
let pixelsPretos = Double(histograma[0] + histograma[1] + histograma[2])
let ratioClippingBranco = pixelsBrancos / totalPixels
let ratioClippingPreto = pixelsPretos / totalPixels
return [
"media_brilho": .numero(media),
"contraste": .numero(contraste),
"exposicao": .texto(exposicao),
"clipping_branco": .numero(ratioClippingBranco),
"clipping_preto": .numero(ratioClippingPreto),
]
}
func construirRequest(_ recurso: String) -> VNRequest? {
switch recurso {
case "faces":
return VNDetectFaceLandmarksRequest()
case "qualidade_facial":
return VNDetectFaceCaptureQualityRequest()
case "pessoas":
let r = VNDetectHumanRectanglesRequest()
r.upperBodyOnly = false
return r
case "pose":
return VNDetectHumanBodyPoseRequest()
case "maos":
let r = VNDetectHumanHandPoseRequest()
r.maximumHandCount = 4
return r
case "ocr":
let r = VNRecognizeTextRequest()
r.recognitionLevel = .accurate
r.recognitionLanguages = ["pt-BR", "en-US"]
return r
case "categorias":
return VNClassifyImageRequest()
case "estetica":
return VNCalculateImageAestheticsScoresRequest()
case "codigos":
return VNDetectBarcodesRequest()
case "horizonte":
return VNDetectHorizonRequest()
case "retangulos":
return VNDetectRectanglesRequest()
case "contornos":
return VNDetectContoursRequest()
case "segmentacao_de_pessoas":
let r = VNGeneratePersonSegmentationRequest()
r.qualityLevel = .balanced
return r
case "brilho":
return nil // processado separadamente via CoreImage, sem Vision
default:
return nil
}
}
func converterResultados(_ observacoes: [VNObservation], para recurso: String) -> [Evidencia] {
switch recurso {
case "faces":
return observacoes.compactMap { observacao -> Evidencia? in
guard let face = observacao as? VNFaceObservation else { return nil }
var valor: [String: JSONValue] = ["bounding_box": .objeto(caixa(face.boundingBox))]
// Orientação da cabeça (enquadramento)
valor["roll"] = .numero(Double(truncating: face.roll ?? 0))
valor["yaw"] = .numero(Double(truncating: face.yaw ?? 0))
valor["pitch"] = .numero(Double(truncating: face.pitch ?? 0))
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")
}
case "qualidade_facial":
return observacoes.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")
}
case "pessoas":
return observacoes.compactMap { observacao -> Evidencia? 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")
}
case "pose":
return observacoes.compactMap { observacao -> Evidencia? 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")
}
case "maos":
return observacoes.compactMap { observacao -> Evidencia? 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")
}
case "ocr":
return observacoes.compactMap { observacao -> Evidencia? 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")
}
case "categorias":
return observacoes.compactMap { observacao -> Evidencia? 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")
}
case "estetica":
return observacoes.compactMap { observacao -> Evidencia? 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")
}
case "codigos":
return observacoes.compactMap { observacao -> Evidencia? 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")
}
case "horizonte":
return observacoes.compactMap { observacao -> Evidencia? 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")
}
case "retangulos":
return observacoes.compactMap { observacao -> Evidencia? 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")
}
case "contornos":
return observacoes.compactMap { observacao -> Evidencia? 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")
}
case "segmentacao_de_pessoas":
return observacoes.compactMap { observacao -> Evidencia? 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")
}
default:
return []
}
}
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] = []
// 0) Processa recursos que não usam Vision (ex: brilho via CoreImage)
for recurso in entrada.recursos {
if recurso == "brilho" {
if let imagem = try? carregarImagem(url) {
let dados = analisarBrilho(imagem)
evidencias.append(Evidencia(tipo: "brilho", valor: dados, confianca: nil, modelo: "CoreImageHistogram"))
} else {
avisos.append("brilho: não foi possível carregar a imagem")
}
}
}
// 1) Constrói um request por recurso solicitado, preservando a ordem
var recursosSolicitados: [String] = []
var requests: [VNRequest] = []
for recurso in entrada.recursos {
if recurso == "brilho" { continue } // já processado acima
if let request = construirRequest(recurso) {
recursosSolicitados.append(recurso)
requests.append(request)
}
}
guard !requests.isEmpty else {
return Saida(evidencias: evidencias, avisos: avisos)
}
// 2) Executa tudo em lote com um único handler (reutiliza a imagem e o CVPixelBuffer)
let (resultados, erros) = executarLote(requests, em: url)
// 3) Processa os resultados que funcionaram
var falhasParaRetry: [(indice: Int, recurso: String, requestOriginal: VNRequest)] = []
for (indice, recurso) in recursosSolicitados.enumerated() {
let observacoes = resultados[indice]
if !observacoes.isEmpty {
evidencias.append(contentsOf: converterResultados(observacoes, para: recurso))
} else if erros[indice] != nil {
falhasParaRetry.append((indice, recurso, requests[indice]))
avisos.append("\(recurso) (lote): falha na análise")
}
}
// 4) Retry seletivo: tenta novamente com CPU-only para requests que falharam no lote
if !falhasParaRetry.isEmpty {
do {
let imagem = try carregarImagem(url)
let handler = VNImageRequestHandler(cgImage: imagem, options: [:])
for falha in falhasParaRetry {
falha.requestOriginal.usesCPUOnly = true
}
let requestsRetry = falhasParaRetry.map { $0.requestOriginal }
try handler.perform(requestsRetry)
// Remove avisos anteriores dos recursos que foram retryados
for falha in falhasParaRetry {
avisos.removeAll { $0.hasPrefix("\(falha.recurso) (lote):") }
}
for (i, falha) in falhasParaRetry.enumerated() {
if let observacoes = requestsRetry[i].results, !observacoes.isEmpty {
evidencias.append(contentsOf: converterResultados(observacoes, para: falha.recurso))
} else {
// Remove o aviso de lote e registra a falha final
avisos.removeAll { $0.hasPrefix("\(falha.recurso) (lote):") }
avisos.append("\(falha.recurso): falhou mesmo com CPU-only")
}
}
} catch {
// Se o retry com CPU-only também falhou como lote, mantém os avisos originais
avisos.append("retry_cpu_only: \(error.localizedDescription)")
}
}
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 featurePrintLote(_ urls: [URL]) -> [VNFeaturePrintObservation?] {
guard !urls.isEmpty else { return [] }
let imagem: CGImage
do { imagem = try carregarImagem(urls[0]) }
catch { return Array(repeating: nil, count: urls.count) }
let handler = VNImageRequestHandler(cgImage: imagem, options: [:])
let requests = urls.map { _ in VNGenerateImageFeaturePrintRequest() }
do {
try handler.perform(requests)
} catch {
return Array(repeating: nil, count: urls.count)
}
return requests.map { request in
request.results?.first as? VNFeaturePrintObservation
}
}
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] = []
// Para sequência, tenta usar feature print em lote para frames consecutivos
// Começa do frame 0 e processa pares (i, i+1)
var i = 0
while i < caminhos.count - 1 {
let urlAtual = URL(fileURLWithPath: caminhos[i])
let urlProximo = URL(fileURLWithPath: caminhos[i + 1])
do {
let fpAtual = try featurePrint(urlAtual)
let fpProximo = try featurePrint(urlProximo)
var distancia: Float = 0
try fpAtual.computeDistance(&distancia, to: fpProximo)
evidencias.append(Evidencia(tipo: "similaridade_visual", valor: [
"frame_inicial": .numero(Double(i)), "frame_final": .numero(Double(i + 1)),
"distancia_feature_print": .numero(Double(distancia)),
"possivel_mudanca_de_cena": .booleano(distancia > 12),
], confianca: nil, modelo: "VNGenerateImageFeaturePrintRequest"))
} catch {
avisos.append("similaridade_visual[\(i)]: \(error.localizedDescription)")
}
i += 1
}
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
let roll = valor.numero("roll").map { String(format: "%.1f", $0 * 180 / .pi) } ?? "?"
let yaw = valor.numero("yaw").map { String(format: "%.1f", $0 * 180 / .pi) } ?? "?"
return "rosto detectado (\(grupos) landmarks), inclinação=\(roll)° rotação=\(yaw)°"
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"
case "brilho":
guard let media = valor.numero("media_brilho") else { return "análise de brilho" }
let exp = valor.texto("exposicao") ?? "?"
let contraste = valor.numero("contraste") ?? 0
return "brilho \(String(format: "%.0f", media * 100))%, contraste \(String(format: "%.0f", contraste * 100))%, exposição: \(exp)"
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 }
}
/// Processa um pedido por linha de stdin e escreve uma resposta por linha em stdout.
/// Mantém o processo vivo entre quadros para não recarregar Vision/Foundation Models a
/// cada frame — o custo de inicialização é o que tornava a análise lenta em lote.
@main struct Principal {
static func main() async {
while let linha = readLine(strippingNewline: true) {
guard !linha.isEmpty, let entradaDados = linha.data(using: .utf8) else { continue }
let saida: Saida
do {
let entrada = try JSONDecoder().decode(Entrada.self, from: entradaDados)
var resultado = (entrada.frames?.count ?? 0) > 1 ? analisarSequencia(entrada) : analisarVision(entrada)
if entrada.resumir, let resumo = await interpretar(resultado) {
resultado = Saida(evidencias: resultado.evidencias + [Evidencia(
tipo: "interpretacao_editorial", valor: ["texto": .texto(resumo)],
confianca: nil, modelo: "AppleFoundationModels")], avisos: resultado.avisos)
}
saida = resultado
} catch {
saida = Saida(evidencias: [], avisos: ["erro_fatal: \(error.localizedDescription)"])
}
guard let dados = try? JSONEncoder().encode(saida), let linhaDeSaida = String(data: dados, encoding: .utf8) else {
FileHandle.standardError.write(Data("Falha ao serializar resposta\n".utf8))
continue
}
print(linhaDeSaida)
fflush(stdout)
}
}
}