はじめに
こちらは個人開発アプリができるまで by am10 Advent Calendar 2024の 11 日目の記事です。
11 日目は Vision を使ってレシート読み取り処理を作成します。
完成形はこんな感じです。
方針決め
このアプリで一番難しいのがレシート読み取り機能でしょう。レシートには様々な形式がありレシートの大量の文字から情報を抽出する必要があります。
すべてのレシートに対応するのは困難なので方針を決めます。
- 読み取る範囲を決める
- 取得する情報は品名と金額のみ
- 読み取るレシートの形式は下記 2 つに限定する
今回は私がよく使うスーパーとコーナンに限定しました(他も必要であればカスタマイズする)。
処理手順
実際のレシート読み取りの処理手順はこんな感じです。
- カメラでレシートを撮影する
- 読み取る範囲を決める
- 2 の範囲で画像をトリミングする
- Vision にトリミングした画像を渡す
- 文字列を読み取る
- 読み取ったデータを 1 行ごとにわける
- 文字列を解析し品名と金額を取得する
- 家計簿データに変換する
文字の読み取り
処理手順も決まったのでレシート読み取り機能を実装していきます。
カメラで撮影
カメラを使うために TARGETS > Info に「Privacy - Camera Usage Description」を追加します。
SwiftUI にはカメラを起動する機能がないようなので UIImagePickerController を使って View を作成する必要があります。
CmaeraView.swift ファイルを作成して下記のように実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
import SwiftUI struct CameraView: UIViewControllerRepresentable { private let allowsEditing: Bool @Binding private var image: UIImage? @Environment(\.dismiss) private var dismiss /// 初期化処理 /// - Parameters: /// - image: 撮影した画像受取用 /// - allowsEditing: UIImagePickerController で編集可能かどうか init(image: Binding<UIImage?>, allowsEditing: Bool = false) { self._image = image self.allowsEditing = allowsEditing } func makeCoordinator() -> Coordinator { Coordinator(self) } func makeUIViewController(context: Context) -> UIImagePickerController { let viewController = UIImagePickerController() viewController.delegate = context.coordinator viewController.allowsEditing = allowsEditing if UIImagePickerController.isSourceTypeAvailable(.camera) { viewController.sourceType = .camera } return viewController } func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) { } } extension CameraView { final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { let parent: CameraView init(_ parent: CameraView) { self.parent = parent } func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { if let uiImage = info[.originalImage] as? UIImage { parent.image = uiImage } parent.dismiss() } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { parent.dismiss() } } } |
これで撮影処理は完了です。
読み取り範囲指定
次に撮影した写真の読み取り範囲を指定する処理を実装します。SwiftUI でもできると思うのですが難しかったので UIKit を使います。
ReceiptOCRViewController.swift ファイルを作成し下記のように実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 |
import UIKit final class ReceiptOCRViewController: UIViewController { /// タップした場所 enum TapRegion { /// 左上 case topLeft /// 右上 case topRight /// 左下 case bottomLeft /// 右下 case bottomRight /// 中心 case center } /// レシート画像 var receiptImage: UIImage? /// 範囲指定用(赤枠のView) private var regionView: UIView! /// レシート画像表示用 private var receiptImageView: UIImageView! /// regionViewのタップした場所 private var tappedRegion: TapRegion? = nil /// tappedRegionでcenterと判定する範囲 private let regionViewCenterRegion = CGSize(width: 40, height: 40) /// regionViewの幅と高さの最小値 private let regionViewMinSize = CGSize(width: 60, height: 60) /// regionViewの初期表示のサイズ private let regionViewDefaultSize = CGSize(width: 200, height: 200) override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground receiptImageView = UIImageView(image: receiptImage) view.addSubview(receiptImageView) receiptImageView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ receiptImageView.topAnchor.constraint(equalTo: view.topAnchor), receiptImageView.leadingAnchor.constraint(equalTo: view.leadingAnchor), receiptImageView.trailingAnchor.constraint(equalTo: view.trailingAnchor), receiptImageView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) regionView = UIView(frame: .init(origin: .zero, size: regionViewDefaultSize)) view.addSubview(regionView) regionView.center = view.center regionView.backgroundColor = .clear regionView.layer.borderColor = UIColor.red.cgColor regionView.layer.borderWidth = 1 } override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { guard let point = touches.first?.location(in: view), regionView.frame.contains(point) else { tappedRegion = nil return } let touchedPoint = view.convert(point, to: regionView) let center = CGPoint(x: regionView.frame.size.width/2, y: regionView.frame.size.height/2) if CGRect(x: center.x - regionViewCenterRegion.width/2, y: center.y - regionViewCenterRegion.height/2, width: regionViewCenterRegion.width, height: regionViewCenterRegion.height).contains(touchedPoint) { tappedRegion = .center } else if touchedPoint.x < center.x { if touchedPoint.y < center.y { tappedRegion = .topLeft } else { tappedRegion = .bottomLeft } } else { if touchedPoint.y < center.y { tappedRegion = .topRight } else { tappedRegion = .bottomRight } } } override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) { guard let point = touches.first?.location(in: view), let tappedRegion = tappedRegion else { return } var frame = regionView.frame switch tappedRegion { case .topLeft: frame.origin.x = point.x frame.origin.y = point.y frame.size.width += regionView.frame.origin.x - point.x frame.size.height += regionView.frame.origin.y - point.y case .topRight: frame.origin.y = point.y frame.size.width = point.x - regionView.frame.origin.x frame.size.height += regionView.frame.origin.y - point.y case .bottomLeft: frame.origin.x = point.x frame.size.width += regionView.frame.origin.x - point.x frame.size.height = point.y - regionView.frame.origin.y case .bottomRight: frame.size.width = point.x - regionView.frame.origin.x frame.size.height = point.y - regionView.frame.origin.y case .center: regionView.center = point return } if frame.size.width < regionViewMinSize.width { frame.size.width = regionViewMinSize.width } if frame.size.height < regionViewMinSize.height { frame.size.height = regionViewMinSize.height } regionView.frame = frame } } |
こんな感じです。
処理の流れは下記です。
- touchedPoint で赤枠のどこをタッチしたか判定
- touchesMoved でどの方向に拡大・縮小するか判定して赤枠をリサイズ
範囲指定はできたので次に指定の範囲で画像を切り抜きます。画面上(UIImageView 上)で指定した範囲は実際の画像(UIImage 上)の範囲とは異なるので下記のように変換する必要があります。
下記のようにすれば読み取るボタン押下で指定範囲の画像を切り抜けるようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
// viewDidLoadに追加 // 読み取りボタン追加 let readButton = UIButton() readButton.setTitle("読み取る", for: .normal) readButton.backgroundColor = .systemBlue readButton.addAction(.init(handler: { [weak self] _ in self?.readImage() }), for: .touchUpInside) view.addSubview(readButton) readButton.translatesAutoresizingMaskIntoConstraints = false // レイアウト修正 NSLayoutConstraint.activate([ receiptImageView.topAnchor.constraint(equalTo: view.topAnchor), receiptImageView.leadingAnchor.constraint(equalTo: view.leadingAnchor), receiptImageView.trailingAnchor.constraint(equalTo: view.trailingAnchor), readButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16), readButton.leadingAnchor.constraint(equalTo: view.leadingAnchor), readButton.trailingAnchor.constraint(equalTo: view.trailingAnchor), readButton.heightAnchor.constraint(equalToConstant: 60), receiptImageView.bottomAnchor.constraint(equalTo: readButton.topAnchor, constant: 16) ]) private func readImage() { guard let rectByImage = receiptImageView?.transformByImage(rect : view.convert(regionView.frame, to: receiptImageView)), let image = receiptImageView.image else { return } // 指定範囲で画像切り抜き let croppedImage = image.cgImage?.cropping(to: rectByImage) } fileprivate extension UIImageView { /// UIImageView上で指定された範囲をUIImage上の範囲に変換する /// - Parameter rect: UIImageView上で指定された範囲 /// - Returns: UIImage上の範囲 func transformByImage(rect : CGRect) -> CGRect? { guard let image = image else { return nil } let transform: CGAffineTransform switch image.imageOrientation { case .left: transform = CGAffineTransform(rotationAngle: .pi / 2).translatedBy(x: 0, y: -image.size.height) case .right: transform = CGAffineTransform(rotationAngle: -.pi / 2).translatedBy(x: -image.size.width, y: 0) case .down: transform = CGAffineTransform(rotationAngle: -.pi).translatedBy(x: -image.size.width, y: -image.size.height) default: transform = .identity } return rect.applying(transform.scaledBy( x: image.size.width / frame.size.width, y: image.size.height / frame.size.height )) } } |
参考
文字列の読み取り(1 行ごと)
指定範囲での画像のトリミングができたのでいよいよ Vision を使って文字列を読み取っていきます。
下記のようにすれば指定範囲の文字列を 1 行ごとに読み取ることができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
import Vision let croppedImage = image.cgImage?.cropping(to: rectByImage) // Visionに渡すように向きを補正 let fixedOrientationImage = UIImage( cgImage: croppedImage!, scale: image.scale, orientation: image.imageOrientation ).fixOrientation() let handler = VNImageRequestHandler(cgImage: fixedOrientationImage.cgImage!, options: [:]) let request = VNRecognizeTextRequest { [weak self] request, error in if let error = error { print(error) } guard let results = request.results else { // 読み取り失敗時の処理 return } // 読み取り結果 let texts = self?.formatData(results.compactMap { $0 as? VNRecognizedTextObservation }) ?? [] // 読み取り成功時の処理 } request.preferBackgroundProcessing = true request.recognitionLanguages = ["ja-JP"] request.usesLanguageCorrection = true try? handler.perform([request]) private func formatData(_ observation: [VNRecognizedTextObservation]) -> [String] { struct Text { let string: String let box: CGRect } var targets: [Text] = observation.compactMap { let string = $0.topCandidates(1).first?.string return string != nil ? .init(string: string!, box: $0.boundingBox) : nil }.sorted { $0.box.origin.x < $1.box.origin.x } // Y座標でまとめる var yGroupingTexts = [[Text]]() var texts = [Text]() while !targets.isEmpty { let target = targets.removeFirst() let rect = CGRect(x: 0, y: target.box.origin.y, width: 1, height: target.box.size.height) texts.append(target) while let index = targets.firstIndex(where: { let box = $0.box let center = CGPoint(x: box.origin.x + box.size.width/2, y: box.origin.y + box.size.height/2) return rect.contains(center) }) { texts.append(targets.remove(at: index)) } yGroupingTexts.append(texts) texts.removeAll() } // 行ごとに半角スペースで結合した文字列のリスト return yGroupingTexts.compactMap { let y = $0.first!.box.origin.y let text = $0.sorted { $0.box.origin.x < $1.box.origin.x }.map { $0.string }.joined(separator: " ") return (y, text) } // y座標で昇順(原点は左下) .sorted { $0.0 > $1.0 }.map { $0.1 } } fileprivate extension UIImage { /// 画像の向きを補正する /// - Returns: 向きを補正した画像 func fixOrientation() -> UIImage { let context = CIContext() let orientation: CGImagePropertyOrientation switch imageOrientation { case .up: orientation = .up case .down: orientation = .down case .left: orientation = .left case .right: orientation = .right case .upMirrored: orientation = .upMirrored case .downMirrored: orientation = .downMirrored case .leftMirrored: orientation = .leftMirrored case .rightMirrored: orientation = .rightMirrored @unknown default: orientation = .up } guard let orientedCIImage = CIImage(image: self)?.oriented(orientation), let cgImage = context.createCGImage(orientedCIImage, from: orientedCIImage.extent) else { print("Image rotation failed.") return self } return UIImage(cgImage: cgImage) } } |
ポイントは下記です。Vision へ渡す際によくわからないですが画像の向きがおかしくなるので向きを補正します。
1 2 3 4 5 6 |
// Visionに渡すように向きを補正 let fixedOrientationImage = UIImage( cgImage: croppedImage!, scale: image.scale, orientation: image.imageOrientation ).fixOrientation() |
formatData で取得した文字データを下記のように処理して 1 行ごとの文字列に整形しています。
- x 座標で昇順にソートする
- 一番左の文字データの矩形を画像の横幅いっぱいに伸ばしその範囲に中心座標がある文字データをグルーピングする
- グルーピングした文字データを半角スペースで連結する
- y 座標で昇順にソートする
View 作成
読み取り処理ができたので SwiftUI で使えるようにします。
下記のようにデリゲートで呼び出し元に通知できるようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
protocol ReceiptOCRViewControllerDelegate: AnyObject { /// レシート画像からの読み取り処理後の通知 /// - Parameter texts: レシート画像から読み取った文字列 /// /// 1 行ずつ返し同じ行の文字列は半角スペースで連結している。 /// 読み取り失敗時は空配列。 func didReadTexts(_ texts: [String]) } weak var delegate: ReceiptOCRViewControllerDelegate? // 読み取り処理の部分に追加 guard let results = request.results else { Task { @MainActor in self?.delegate?.didReadTexts([]) } return } let texts = self?.formatData(results.compactMap { $0 as? VNRecognizedTextObservation }) ?? [] Task { @MainActor in self?.delegate?.didReadTexts(texts) } |
ReceiptOCRView.swift ファイルを作成し下記のように実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
import SwiftUI struct ReceiptOCRView: UIViewControllerRepresentable { private let receiptImage: UIImage? @Binding private var receiptTexts: [String] @Environment(\.dismiss) private var dismiss /// 初期化処理 /// - Parameters: /// - receiptImage: レシート画像 /// - receiptTexts: レシートの読み取り結果受取用 init(receiptImage: UIImage?, receiptTexts: Binding<[String]>) { self.receiptImage = receiptImage self._receiptTexts = receiptTexts } func makeCoordinator() -> Coordinator { Coordinator(self) } func makeUIViewController(context: Context) -> ReceiptOCRViewController { let viewController = ReceiptOCRViewController() viewController.receiptImage = receiptImage viewController.delegate = context.coordinator return viewController } func updateUIViewController(_ uiViewController: ReceiptOCRViewController, context: Context) {} } extension ReceiptOCRView { class Coordinator: NSObject, @preconcurrency ReceiptOCRViewControllerDelegate { let parent: ReceiptOCRView init(_ parent: ReceiptOCRView) { self.parent = parent } @MainActor func didReadTexts(_ texts: [String]) { parent.receiptTexts = texts parent.dismiss() } } } |
このまま RegisterView に表示処理を書いてもいいのですが複雑になりそうなので別クラスにわけます。
ReadReceiptButton.swift ファイルを作成し下記のように実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
import SwiftUI struct ReadReceiptButton: View { /// レシートの読み取り結果 @Binding var receiptTexts: [String] @State private var receiptImage: UIImage? @State private var isCameraViewShown = false @State private var isOCRViewShown = false var body: some View { Button { isCameraViewShown = true } label: { HStack { Image(systemName: "text.viewfinder") Text("レシートを読み取る") } }.fullScreenCover(isPresented: $isCameraViewShown) { CameraView(image: $receiptImage) }.fullScreenCover(isPresented: $isOCRViewShown) { ReceiptOCRView(receiptImage: receiptImage, receiptTexts: $receiptTexts) }.onChange(of: receiptImage) { _, _ in isOCRViewShown = true } } } |
RegisterView を下記のように修正します。
1 2 3 4 5 |
// 追加 @State var receiptTexts: [String] = [] // レシート読み取りボタンをこっちに修正 ReadReceiptButton(receiptTexts: $receiptTexts) |
文字列の解析
登録画面からレシート読み取り画面を表示できるようになったので次は読み取った文字列から品名と金額を取得します。
ReceiptDataFormatter.swift ファイルを作成し下記のように実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
import Foundation struct ReceiptDataFormatter { enum ReceiptType { /// 万代 case mandai /// コーナン case kohnan } struct FormattedData { /// 品名 let name: String /// 金額 let price: Int } /// レシートから読み取った文字列を品名と金額に変換する /// - Parameter receiptTexts: レシートから読み取った文字列(1行ごと、半角スペースで連結) /// - Returns: 品名と金額への変換結果 func format(_ receiptTexts: [String]) -> [FormattedData] { switch receiptType(from: receiptTexts) { case .mandai: return mandaiFormat(receiptTexts) case .kohnan: return kohnanFormat(receiptTexts) } } private func receiptType(from receiptTexts: [String]) -> ReceiptType { if receiptTexts.contains(where: { let components = $0.components(separatedBy: " ") return $0.hasPrefix("#") && components.count == 2 && components[1].count == 16 && components[1].isAlphanumeric }) && receiptTexts.contains(where: { $0.hasSuffix("内") && $0.replacingOccurrences(of: "内", with: "").isNumeric }) { // 「#015 JAN4548927034969」と「217内」形式のデータがある場合はコーナン return .kohnan } return .mandai } } extension ReceiptDataFormatter { private func mandaiFormat(_ receiptTexts: [String]) -> [FormattedData] { // 末尾が「¥1,899」形式のデータを抽出 let filtered = receiptTexts.filter { let components = $0.components(separatedBy: " ") guard let priceText = components.last, priceText.hasPrefix("¥") else { return false } return priceText.replacingOccurrences(of: "¥", with: "") .replacingOccurrences(of: ",", with: "") .isNumeric } return filtered.map { var components = $0.components(separatedBy: " ") // 末尾の「¥1,899」形式の文字列から金額取得 let priceText = components.popLast()! .replacingOccurrences(of: "¥", with: "") .replacingOccurrences(of: ",", with: "") let price = Int(priceText) ?? 0 let first = components.removeFirst() if first.count == 4 && first.contains("#") && String(first.suffix(2)).isNumeric { // 「ソ#11」形式の文字列を除去する return FormattedData(name: components.joined(separator: " "), price: price) } return FormattedData(name: ([first] + components).joined(separator: " "), price: price) } } private func kohnanFormat(_ receiptTexts: [String]) -> [FormattedData] { // 「#015 JAN4548927034969」形式のデータを除去する var filtered = receiptTexts.filter { let components = $0.components(separatedBy: " ") guard let last = components.last else { return false } return last.count != 16 || !last.isAlphanumeric } var results = [FormattedData]() var name = "" while !filtered.isEmpty { let value = filtered.removeFirst() if !value.hasSuffix("内") { name = name + value } else { // 「1,899内」形式の文字列から金額取得 let priceText = value.replacingOccurrences(of: "内", with: "") .replacingOccurrences(of: ",", with: "") if let price = Int(priceText) { results.append(.init(name: name, price: price)) name = "" } else { name = name + value } } } return results } } fileprivate extension String { /// 数値のみかどうか var isNumeric: Bool { return !isEmpty && range(of: "[^0-9]", options: .regularExpression) == nil } /// アルファベットと数値のみかどうか var isAlphanumeric: Bool { return !isEmpty && range(of: "[^a-zA-Z0-9]", options: .regularExpression) == nil } } |
これで万代とコーナン形式のレシートデータから品名と金額を取得できるようになりました。他のレシートにも対応したい場合は ReceiptDataFormatter に処理を追加していきます。
家計簿データに整形する
最後に取得した品名と金額から家計簿データに変換します。購入日、項目、サブ項目は登録画面で入力されている値を設定するようにします。
RegisterView のレシート読み取りの処理を下記のように修正します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
ReadReceiptButton(receiptTexts: $receiptTexts) .onChange(of: receiptTexts) { _, newValue in if newValue.isEmpty { return } let results = ReceiptDataFormatter().format(newValue).map { Item( id: UUID().uuidString, date: item.date, name: $0.name, price: $0.price, categoryValue: item.categoryValue, categorySubValue: item.categorySubValue == Category.unselectedValue ? nil : item.categorySubValue ) } items.append(contentsOf: results) receiptTexts.removeAll() } |
これでレシート読み取り機能は完成です(消費税に関しては別途手入力するか無視でいいかなと)。
おわりに
このアプリの一番の難所が終わりました。
サンプルのレシートは読み取れるようになりましたが他のレシートも読み取れるかはわからないので ReceiptDataFormatter の処理はアプリを使いつつ調整していければと思います。
必要であれば他の店のレシートも読み取れるように修正していきます。
明日はエラーハンドリングなど細かい調整をする予定です。
コメント