Apple VisionKit收据OCR结构化提取问题咨询
我正在用Apple的VisionKit框架对收据图像做OCR扫描,目标是提取每件商品及其对应价格。VisionKit的文本提取能力没问题,能完整识别图像里的文本,但文本结构始终有问题,商品和价格没法对应。比如扫描某收据后,VNRecognizeTextRequest输出的文本如下:
44 Bierhaus Bierhaus NYC 712 Third Avenue New York, NY 10017 Server: Tiffany R Check #44 Guest Count: 3 Ordered: 1 Lt Delicator 1L Hofbräu Dunkel .5L Hofbräu Original Bierhaus Burger Well Done Bratwurst Spicy Mustard Chicken Bratwurst Spicy Mustard Subtotal Tax Total Table 304 12/31/23 7:27 PM $25.00 $19.00 $10.00 $19.00 $17.00 $17.00 $107.00 $9.48 $116.48 Suggested Tip: 22%: (Tip $23.54 Total $140.02) 20%: (Tip $21.40 Total $137.88) 18%: (Tip $19.26 Total $135.74) Tip percentages are based on the check price before taxes. Oktoberfest All Year Round! Powered by Hofbräu Bier
请问能不能调整VNRecognizeTextRequest的配置项,让商品和对应价格被识别为关联结构?现有数据太非结构化,我该怎么解析才能达成(商品名, 价格)的格式化提取目标?
以下是我的实现代码:
import Foundation import Vision import VisionKit final class TextRecognizer { let cameraScan: VNDocumentCameraScan? let uploadedImage: UIImage? init(cameraScan: VNDocumentCameraScan?, image: UIImage?) { self.cameraScan = cameraScan self.uploadedImage = image } private let queue = DispatchQueue(label: "scan-codes", qos: .default, attributes: [], autoreleaseFrequency: .workItem) func recognizeText(withCompletionHandler completionHandler: @escaping ([String]) -> Void) { queue.async { let minimumTextHeight: Float = 1 if let cameraScan = self.cameraScan { let images = (0..<cameraScan.pageCount).compactMap({ cameraScan.imageOfPage(at: $0).cgImage }) let imagesAndRequests = images.map({ (image: $0, request: VNRecognizeTextRequest()) }) let textPerPage = imagesAndRequests.map { image, request -> String in // Configure the request for receipt recognition request.recognitionLevel = .accurate request.usesLanguageCorrection = true request.minimumTextHeight = minimumTextHeight let handler = VNImageRequestHandler(cgImage: image, options: [:]) do { try handler.perform([request]) guard let observations = request.results else { return "" } return observations.compactMap({ $0.topCandidates(1).first?.string }).joined(separator: "\n") } catch { print(error) return "" } } DispatchQueue.main.async { completionHandler(textPerPage) } } else if let image = self.uploadedImage { let images = (0...0).compactMap({ _ in image.cgImage }) let imagesAndRequests = images.map({ (image: $0, request: VNRecognizeTextRequest()) }) let textPerPage = imagesAndRequests.map { image, request -> String in // Configure the request for receipt recognition request.recognitionLevel = .accurate request.usesLanguageCorrection = false request.minimumTextHeight = minimumTextHeight let handler = VNImageRequestHandler(cgImage: image, options: [:]) do { try handler.perform([request]) guard let observations = request.results else { return "" } return observations.compactMap({ $0.topCandidates(1).first?.string }).joined(separator: "\n") } catch { print(error) return "" } } DispatchQueue.main.async { completionHandler(textPerPage) } } } } }
一、能否通过调整VNRecognizeTextRequest配置实现关联结构识别?
不行。VNRecognizeTextRequest的核心是文本识别,它只会输出识别到的文本内容及其位置信息,不会自动分析文本的语义结构(比如商品和价格的对应关系)。你能调整的配置(比如recognitionLevel、usesLanguageCorrection)只会影响文本识别的准确率,无法直接让它输出关联好的商品-价格结构。
要实现关联,必须自己利用Vision提供的文本位置信息,或者对识别后的文本做结构化解析。
二、解析非结构化文本提取商品-价格对的方法
针对你提供的收据格式,有两种可行思路:
思路1:利用文本的位置坐标(推荐)
VNRecognizeTextObservation不仅包含文本内容,还包含文本在图像中的boundingBox(边界框)。你可以通过比较商品行和价格行的Y坐标,判断哪些商品和价格属于同一行。
修改你的代码,不要直接拼接文本,而是保留每个观察对象的位置信息:
// 定义结构体存储文本和位置 struct TextWithPosition { let text: String let y: CGFloat // 取边界框的中点Y坐标,用于判断垂直位置 } // 在perform成功后,收集带位置的文本数据 let textWithPositions = observations.compactMap { observation -> TextWithPosition? in guard let text = observation.topCandidates(1).first?.string else { return nil } // boundingBox是归一化坐标,若需转换为图像实际坐标,可结合图像尺寸计算 let yPosition = observation.boundingBox.midY return TextWithPosition(text: text, y: yPosition) } // 按Y坐标从上到下排序 let sortedTexts = textWithPositions.sorted { $0.y < $1.y }
之后的处理步骤:
- 定位商品区域:从
Ordered:之后到Subtotal之前的内容 - 定位价格区域:从
Subtotal之后的价格行中筛选出对应数量的价格 - 匹配关联:遍历商品行,找到Y坐标最接近的价格行,建立商品-价格对应关系(收据中商品和价格通常左右对齐,Y坐标高度重合)
思路2:基于文本顺序的匹配(适合格式固定的收据)
观察你提供的收据,商品列表和价格列表是一一对应的:
- 商品共6项(含备注行,如
Bierhaus Burger和Well Done属于同一件商品) - 价格正好有6个(
$25.00到$17.00),之后是小计、税、总计
实现步骤:
let lines = text.components(separatedBy: "\n").filter { !$0.isEmpty } // 定位关键标记的索引 guard let orderedIndex = lines.firstIndex(of: "Ordered:"), let subtotalIndex = lines.firstIndex(of: "Subtotal") else { return } // 提取并合并商品行 var items: [String] = [] var currentItem = "" for i in orderedIndex+1..<subtotalIndex { let line = lines[i] // 判断是否为新商品:行首是数字(含小数) let isNewItem = line.range(of: "^\\d+(.\\d+)?", options: .regularExpression) != nil if isNewItem { if !currentItem.isEmpty { items.append(currentItem.trimmingCharacters(in: .whitespaces)) } currentItem = line } else { currentItem += " " + line } } if !currentItem.isEmpty { items.append(currentItem.trimmingCharacters(in: .whitespaces)) } // 提取对应数量的价格行 var prices: [String] = [] for i in subtotalIndex+1..<lines.count { let line = lines[i] if line.range(of: "^\\$\\d+\\.\\d{2}$", options: .regularExpression) != nil { prices.append(line) if prices.count == items.count { break } } } // 生成商品-价格对 let itemPricePairs = zip(items, prices).map { ($0, $1) }
这个方法依赖收据格式固定,若遇到商品和价格混排的场景会失效,但对你提供的格式完全适用。
三、额外优化建议
- 统一关闭
usesLanguageCorrection:收据含大量品牌名、特殊词汇,语言修正可能改错文本 - 调整
minimumTextHeight:根据实际收据的文本大小设置,避免识别到无关的小文字 - 图像预处理:裁剪收据区域、增强对比度,能提升OCR识别准确率
内容的提问来源于stack exchange,提问作者James

