レシートを OCR して合計金額を読み取るスクリプトを書いた

家計簿サービスとして Zaim を愛用している。今までは主にレシートを Zaim iOS アプリで写真撮影&内蔵の OCR 機能で読み込みという方法で記録を行っていたが、単にレシート撮影が面倒だということからデータ取り込み用にスキャナ ScanSnap ix100 を入手した。

今回は Google Apps Script (GAS) を用いて、Google Cloud Vision API によるレシート画像の OCR、および読み取り結果からの合計金額の抽出を行う。色々検索してみたところ何らかの方法でレシートの OCR をしているものは多数 (こちら など) 見つかったものの、合計金額の読み取りに成功しているものは見つからなかったので自力で何とかすることになった。コード全体は こちらのレポジトリprocessReceipt.js にまとめてある。

Google Cloud Vision API を利用して OCR を行う

  • GAS から Google Cloud Vision API を利用する方法については こちら などに詳しい。Cloud Vision API は 1000 リクエスト / 月を超えると有料になるようだが、一ヶ月に 1000 回も買い物をすることはないので問題ないだろう。
  • さて、次のようなレシート画像の読み込みを行うことを考える。 f:id:I-was-a-Ki:20200229174806p:plain

  • Google Cloud Vision API (Reference) に上記画像を渡し、レスポンスの json から読み取り結果全体、つまり responses[0].textAnnotations[0].description を表示してみると以下のようになる (個人情報的にアレな部分は * で表示してある)。

ライ7
**********店
***-***-***
領収証
2020年02月23日(日)18:36 レジ****
責*************
チ*************
* キューピーマヨネーズ
* あじわい牛乳1000ml¥189
* はごろもシーチキンM
* Lピーナツあげ
* グランドアルトバイエルン¥358
A* ブルボンアルフォートF
小計
(外8% 対象
外8%
支払合計
お預り
お釣り
お買上点数
* 印は軽減税率(8%)適用の商品です
¥178
¥398
¥98
¥258
¥1, 479
¥1, 479)
¥118
¥1,597
¥2, 000
¥403
6点
Ponta
会員番号
今回取引ポイント
*ポイントの反映にはお時間を
いただく場合があります。
*ポイント残高,有効期限は
各社サイトでご確認ください。
*****XXXXXXXXXX
レシート********
店********

こんな感じで文字や数字自体はかなり正確に読み取ってくれるものの、位置がぐちゃぐちゃになっているせいで ¥ 付き数字のうちどれが合計金額なのかさっぱり分からない。

OCR 結果から合計金額を読み取る

合計金額抽出のためには、各文字の位置情報を保存した responses[0].textAnnotations を参照する必要がある。textAnnotations の抜粋を以下に示す。

[
  {
    "description": "支払",
    "boundingPoly": {
      "vertices": [
        {
          "x": 22,
          "y": 906
        },
        {
          "x": 95,
          "y": 906
        },
        {
          "x": 95,
          "y": 943
        },
        {
          "x": 22,
          "y": 943
        }
      ]
    }
  },
  {
    "description": "合計",
    "boundingPoly": {
      "vertices": [
        {
          "x": 100,
          "y": 907
        },
        {
          "x": 171,
          "y": 907
        },
        {
          "x": 171,
          "y": 942
        },
        {
          "x": 100,
          "y": 942
        }
      ]
    }
  }
]

description 内に含まれている文字 (列) を囲う bounding box の位置が、画像の左上を原点とする xy 座標で格納されている。そこで、合計金額を抽出するためには「合計」のある行と同じ行にある数字を発見し、それを抽出すればよいことになる。イメージ図はこんな感じ。 f:id:I-was-a-Ki:20200229174859p:plain 以下のコードでは detectKeyWordHeight() で「合」を含む bounding box の y 座標の最大値・最小値の平均、つまり goukeiHeight を求めている。findAmountByGoukei() では annotations を上から順に走査し、annotation の y 座標の最大値 (下端) が goukeiHeight をはじめて超えたもの (¥1597) を合計金額として抽出している。

// キーワードの位置 (y 座標) を返す
function detectKeyWordHeight(textAnnotations, keyWord) {
    var regExp = new RegExp(keyWord);
    for (var i = 1; i < textAnnotations.length; i++) {
        var text = textAnnotations[i].description;
        if (text.match(regExp)) {
            var keyWordUpperHeight = textAnnotations[i].boundingPoly.vertices[0].y;
            var keyWordLowerHeight = textAnnotations[i].boundingPoly.vertices[3].y;
            var keyWordHeight = (keyWordUpperHeight + keyWordLowerHeight) / 2;
            return keyWordHeight;
        }
    }
    Logger.log("Failed to detect " + keyWord);
    return false;
}

// 「合」と同じ行 or すぐ下にある ¥ 入りの文字列を見つける
function findAmountByGoukei(textAnnotations) {
    var goukeiHeight = detectKeyWordHeight(textAnnotations, "合");
    for (var i = 1; i < textAnnotations.length; i++) {
        // ¥ が入っていないものはスキップ
        if (!correctYenMark(textAnnotations[i].description).match(/\¥/)) {
            continue;
        }
        var textLowerHeight = textAnnotations[i].boundingPoly.vertices[3].y;
        // 「合」のある位置より下のものを捕捉する
        if (textLowerHeight >= goukeiHeight) {
            return parseAmount(textAnnotations, i);
        }
    }
    return false;
}

これで一件落着すればいいのだが、以下のようなレシートで問題が発生することがある。 f:id:I-was-a-Ki:20200229174924p:plain

どうやら Google Cloud Vision API は横長に引き伸ばされた文字に強くないらしく、上記画像のような「合計」を正しく認識してくれないのである。対処法としては画像の縦横比を変えて「合計」を正しい形に戻すことが考えられるが、GAS でリサイズを行うメソッドは用意されていない (こちら を参照) ようなので別の方法を採る必要がある。ここでは、「合計」のすぐ下にある行には「内消費税」という文字がありがちであるという知見 (???) に基づき、「費」の字のすぐ上の行にある数字を抽出する方針とする。めちゃくちゃアホっぽいが、これが意外と上手くいく。

// 「費」のすぐ上にある ¥ 入りの文字列を見つける
function findAmountByShohi(textAnnotations) {
    var shohiHeight = detectKeyWordHeight(textAnnotations, "費");
    // 下から探していくので逆順にソートする
    for (var i = textAnnotations.length - 1; i > 0; i—) {
        // ¥ が入っていないものはスキップ
        if (!correctYenMark(textAnnotations[i].description).match(/\¥/)) {
            continue;
        }
        var textLowerHeight = textAnnotations[i].boundingPoly.vertices[3].y;
        // 「費」のある位置より上のものを捕捉する
        if (textLowerHeight <= shohiHeight) {
            return parseAmount(textAnnotations, i);
        }
    }
    return false;
}

今度は下から探していくので逆順にソートを行っている。実装としては、「合」の字での検索に失敗したとき「費」の字での探索を試してみる形にするのがいいだろう。

感想

結果的にめちゃくちゃヒューリスティックな実装になってしまったが、体感的に 9 割くらいは正しく認識してくれるようになったしまあいいかなという感じ。こんなのではなくてなんかかっこいいアルゴリズムをご存じの方は教えて下さい。