You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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 }

之后的处理步骤:

  1. 定位商品区域:从Ordered:之后到Subtotal之前的内容
  2. 定位价格区域:从Subtotal之后的价格行中筛选出对应数量的价格
  3. 匹配关联:遍历商品行,找到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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.02 14:24:53