import Foundation import Vision import UIKit enum OCRError: Error { case noImage } /// 端侧文字识别(Apple Vision,100% 本地,无网络)。 /// 用于「记录指标 · 拍照识别」:VL 直接读密集小字不稳,改为先 OCR 出文本,再交 LLM 结构化。 enum OCRService { /// 识别图中文字,按阅读顺序(自上而下、行内自左而右)拼成纯文本。 /// 中英文混排;表格行会尽量保持在同一行,便于 LLM 把「指标名 数值 范围 单位」对齐解析。 static func recognizeText(in cgImage: CGImage) async throws -> String { try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in DispatchQueue.global(qos: .userInitiated).async { let request = VNRecognizeTextRequest() request.recognitionLevel = .accurate request.usesLanguageCorrection = true // 中文(简/繁)+ 英文;化验单常见中英文与数字混排。 request.recognitionLanguages = ["zh-Hans", "zh-Hant", "en-US"] let handler = VNImageRequestHandler(cgImage: cgImage, orientation: .up, options: [:]) do { try handler.perform([request]) let obs = (request.results as? [VNRecognizedTextObservation]) ?? [] cont.resume(returning: assemble(obs)) } catch { cont.resume(throwing: error) } } } } /// UIImage 便捷入口。 static func recognizeText(in image: UIImage) async throws -> String { guard let cg = image.cgImage else { throw OCRError.noImage } return try await recognizeText(in: cg) } /// 把散落的 observation 还原成阅读顺序文本。 /// Vision 坐标系原点在左下、y 向上;按 midY 降序分行(同行 y 接近),行内按 minX 升序。 private static func assemble(_ obs: [VNRecognizedTextObservation]) -> String { let items: [(rect: CGRect, text: String)] = obs.compactMap { o in guard let t = o.topCandidates(1).first?.string, !t.isEmpty else { return nil } return (o.boundingBox, t) } guard !items.isEmpty else { return "" } let sorted = items.sorted { $0.rect.midY > $1.rect.midY } let yTol: CGFloat = 0.012 // 行高容差(归一化坐标);同一行的 cell midY 差异通常 < 此值 var rows: [[(rect: CGRect, text: String)]] = [] var rowY: [CGFloat] = [] // 各行内 midY 的运行平均,做锚点比单看首元素更稳(抗轻微行漂移) for item in sorted { if let i = rows.indices.last, abs(rowY[i] - item.rect.midY) < yTol { rows[i].append(item) rowY[i] = (rowY[i] * CGFloat(rows[i].count - 1) + item.rect.midY) / CGFloat(rows[i].count) } else { rows.append([item]) rowY.append(item.rect.midY) } } return rows.map { row in row.sorted { $0.rect.minX < $1.rect.minX } .map(\.text) .joined(separator: " ") }.joined(separator: "\n") } }