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/
This commit is contained in:
@@ -53,17 +53,54 @@ func ponto(_ point: VNRecognizedPoint) -> JSONValue {
|
||||
"confianca": .numero(Double(point.confidence))])
|
||||
}
|
||||
|
||||
func executar<T: VNRequest>(_ request: T, em url: URL) throws -> [VNObservation] {
|
||||
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) }
|
||||
@@ -79,25 +116,124 @@ func coberturaDaMascara(_ pixelBuffer: CVPixelBuffer) -> Double {
|
||||
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] = []
|
||||
/// 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)
|
||||
|
||||
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)") }
|
||||
// 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")]
|
||||
}
|
||||
|
||||
tentar("faces") {
|
||||
let request = VNDetectFaceLandmarksRequest()
|
||||
return try executar(request, em: url).compactMap { observacao -> Evidencia? in
|
||||
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),
|
||||
@@ -111,101 +247,67 @@ func analisarVision(_ entrada: Entrada) -> Saida {
|
||||
}
|
||||
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
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("pessoas") {
|
||||
let request = VNDetectHumanRectanglesRequest()
|
||||
request.upperBodyOnly = false
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("pose") {
|
||||
let request = VNDetectHumanBodyPoseRequest()
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("maos") {
|
||||
let request = VNDetectHumanHandPoseRequest()
|
||||
request.maximumHandCount = 4
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("ocr") {
|
||||
let request = VNRecognizeTextRequest()
|
||||
request.recognitionLevel = .accurate
|
||||
request.recognitionLanguages = ["pt-BR", "en-US"]
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("categorias") {
|
||||
let request = VNClassifyImageRequest()
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("estetica") {
|
||||
let request = VNCalculateImageAestheticsScoresRequest()
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("codigos") {
|
||||
let request = VNDetectBarcodesRequest()
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("horizonte") {
|
||||
let request = VNDetectHorizonRequest()
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("retangulos") {
|
||||
let request = VNDetectRectanglesRequest()
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
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),
|
||||
@@ -214,25 +316,104 @@ func analisarVision(_ entrada: Entrada) -> Saida {
|
||||
"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
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
tentar("segmentacao_de_pessoas") {
|
||||
let request = VNGeneratePersonSegmentationRequest()
|
||||
request.qualityLevel = .balanced
|
||||
return try executar(request, em: url).compactMap { observacao in
|
||||
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)
|
||||
@@ -246,22 +427,54 @@ func featurePrint(_ url: URL) throws -> VNFeaturePrintObservation {
|
||||
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] = []
|
||||
for indice in 0..<(caminhos.count - 1) {
|
||||
|
||||
// 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 featurePrint(URL(fileURLWithPath: caminhos[indice])).computeDistance(
|
||||
&distancia, to: featurePrint(URL(fileURLWithPath: caminhos[indice + 1])))
|
||||
try fpAtual.computeDistance(&distancia, to: fpProximo)
|
||||
evidencias.append(Evidencia(tipo: "similaridade_visual", valor: [
|
||||
"frame_inicial": .numero(Double(indice)), "frame_final": .numero(Double(indice + 1)),
|
||||
"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[\(indice)]: \(error.localizedDescription)") }
|
||||
} catch {
|
||||
avisos.append("similaridade_visual[\(i)]: \(error.localizedDescription)")
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return Saida(evidencias: evidencias, avisos: avisos)
|
||||
}
|
||||
@@ -292,7 +505,9 @@ func descreverEvidencia(_ evidencia: Evidencia) -> String {
|
||||
switch evidencia.tipo {
|
||||
case "rosto":
|
||||
let grupos = valor.objeto("landmarks")?.count ?? 0
|
||||
return "rosto detectado (\(grupos) grupos de landmarks mapeados)"
|
||||
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))"
|
||||
@@ -335,6 +550,11 @@ func descreverEvidencia(_ evidencia: Evidencia) -> String {
|
||||
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
|
||||
}
|
||||
@@ -360,7 +580,7 @@ func interpretar(_ saida: Saida) async -> String? {
|
||||
let saida: Saida
|
||||
do {
|
||||
let entrada = try JSONDecoder().decode(Entrada.self, from: entradaDados)
|
||||
var resultado = entrada.frames?.count ?? 0 > 1 ? analisarSequencia(entrada) : analisarVision(entrada)
|
||||
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)],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Adapta o faster-whisper para a transcrição local do Scanner."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
from ...scanner.modelos import PalavraDeTranscricao, SegmentoDeTranscricao
|
||||
|
||||
@@ -10,7 +10,8 @@ class ProviderDeTranscricaoLocal:
|
||||
"""Executa transcrição local em lote, sem transmitir a mídia."""
|
||||
|
||||
def __init__(self, modelo: str, dispositivo: str = "cpu", tipo_de_calculo: str = "int8",
|
||||
idioma: str = "pt", tamanho_do_lote: int = 16) -> None:
|
||||
idioma: str = "pt", tamanho_do_lote: int = 16,
|
||||
ao_progresso: Callable[[float], None] | None = None) -> None:
|
||||
"""Carrega o modelo local e configura o tamanho do lote de inferência."""
|
||||
if tamanho_do_lote < 1:
|
||||
raise ValueError("O tamanho do lote deve ser maior que zero.")
|
||||
@@ -22,17 +23,29 @@ class ProviderDeTranscricaoLocal:
|
||||
self.pipeline = BatchedInferencePipeline(model=self.modelo)
|
||||
self.idioma = idioma
|
||||
self.tamanho_do_lote = tamanho_do_lote
|
||||
self.ao_progresso = ao_progresso
|
||||
|
||||
def transcrever(self, clipe: Any) -> list[SegmentoDeTranscricao]:
|
||||
"""Transcreve o áudio com VAD, timestamps e inferência em lote."""
|
||||
arquivo = Path(clipe.arquivo)
|
||||
if not arquivo.is_file():
|
||||
raise FileNotFoundError(f"Arquivo de áudio não encontrado: {arquivo}")
|
||||
segmentos, _ = self.pipeline.transcribe(
|
||||
segmentos, informacoes = self.pipeline.transcribe(
|
||||
str(arquivo), language=self.idioma, vad_filter=True,
|
||||
word_timestamps=True, batch_size=self.tamanho_do_lote,
|
||||
)
|
||||
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]
|
||||
duracao = float(getattr(informacoes, "duration", 0.0) or 0.0)
|
||||
resultado: list[SegmentoDeTranscricao] = []
|
||||
for segmento in segmentos:
|
||||
resultado.append(SegmentoDeTranscricao(
|
||||
float(segmento.start), float(segmento.end), segmento.text.strip(),
|
||||
None, tuple(PalavraDeTranscricao(
|
||||
palavra.word.strip(), float(palavra.start), float(palavra.end),
|
||||
getattr(palavra, "probability", None)
|
||||
) for palavra in (segmento.words or []) if palavra.word.strip())
|
||||
))
|
||||
if self.ao_progresso and duracao > 0:
|
||||
self.ao_progresso(min(99.0, max(0.0, float(segmento.end) / duracao * 100)))
|
||||
if self.ao_progresso:
|
||||
self.ao_progresso(100.0)
|
||||
return resultado
|
||||
|
||||
Reference in New Issue
Block a user