Cloud Vision API でレシートを記録するプログラムを作った

OpenSiv3D と Cloud Vision API を使って購入記録を管理するツールを作った話です。

# アプリケーションの概要

自分の欲しいレシート管理アプリは、こんな仕様のものです。

- レシート写真からある程度自動的に内容を読み取れる
- 一度に複数枚のレシートを処理できる
- 読み取ったデータを手動で修正できる（重要）
- データエクスポートできる（重要）

スマホで使える家計管理系のアプリは単体で完結するものがほとんどで、エクスポートに対応しているものは探した限り見つかりませんでした。自分は入力だけをアプリで済ませて、データは再利用できる場所にまとめたかったので、この用途に合うものを自作することにしました。

文字認識には精度が高く、十分な無料枠のある Google の Cloud Vision API を使いました。

この記事では、Cloud Vision API を OpenSiv3D プログラムに組み込んで使うまでの手順と、記録作業を効率化するために行った工夫をまとめます。

# できたもの
参考までにプロジェクト全体を公開しました（ほぼ個人用なので汎用性は低いと思います）

https://github.com/agehama/ReceiptOCR

# OCR を行うまで

Cloud Vision API は Google Cloud の提供する Vision AI サービスの一つです。

https://cloud.google.com/vision

アプリケーションから使用するには、クライアントライブラリを通して画像ファイルをサーバーに送る必要があります。

Google Cloud のクライアントライブラリは多くのプログラミング言語をサポートしており、C++ も対象に入っています。ただし、C++ のライブラリはビルドがとても難しく、MSVC でテストされているのかもよくわからなかったので、これを直接使うことは断念しました。

https://cloud.google.com/vision/docs/libraries?hl=ja

代わりに C# のクライアントライブラリは Nuget から追加するだけですぐに使えたので、API の呼び出し部分は C# で独立した exe として作り、C++ からこれを起動して標準入出力を通して使う方針にしました。

## 設定とインストール

Google Cloud のサービスを利用するには、まずブラウザでサービスのプロジェクトの設定行ったあと、アクセス用の API キーを作成して、ローカルの環境変数でキーを参照できるようにする必要があります。

以下の記事でわかりやすく解説されているので、この手順にそのまま従いました。

https://atmarkit.itmedia.co.jp/ait/articles/1712/22/news033.html

## Cloud Vision API に OCR のリクエストを送る
Visual Studio で C# コンソールアプリを作成し、プロジェクトの NuGet パッケージ管理から `Google.Cloud.Vision.V1` を追加します。これで Cloud Vision API の機能を使えるようになりました。

以下は`test.jpg`の OCR 結果をコンソールに出力する簡単なサンプルプログラムです。

```cs:Main.cs
using Google.Cloud.Vision.V1;

var client = ImageAnnotatorClient.Create();

var feature = new Feature
{
    Type = Feature.Types.Type.TextDetection,
    MaxResults = 100,
};
var request = new AnnotateImageRequest()
{
    Image = Image.FromFile("test.jpg"),
    Features = { feature },
};
var response = client.Annotate(request);

foreach (var item in response.TextAnnotations)
{
    Console.WriteLine(item.Description);
}
```

- `test.jpg`

![test.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/93a3b32d-4cfd-ccbc-9558-dd4fb366bd22.jpeg)

- コンソール出力結果

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/2c7bce69-a606-c983-7b34-b01506d82f8d.png)

### TextAnnotations と FullTextAnnotation

API のレスポンスには `TextAnnotations` と `FullTextAnnotation` という２通りの形式でデータが入っており、それぞれが要素ごとに外接長方形（OBB）の情報を持っています。前者のデータはリスト構造で、後者は階層構造です。

サンプル画像の解析結果をもとに、両者の内容を比較してみます。

#### TextAnnotations の中身

`TextAnnotations` の中身を見ると、最初の要素に解析結果の全文、２つ目以降にバラした各単語が入っています。

最初の全文に関しては、座標で文字のまとまりを識別した上で、要素が離れている場合はスペース、縦方向にずれる場合は改行が挿入されるようです。

```text:response.TextAnnotations[0].Description
FamilyMart
アメ村三角公園前店
大阪府大阪市中央区西心斎橋2丁目
18-6
電話: 06-6484-2006
登録番号: T8120001031074
2024年7月6日 (土) 12:47
レジ 2-1377
貴No. 165
領取 証
ルマンドバニラF
合計
¥350
¥350
(8% 対象
¥350)
(内消費税等
¥25)
クレジットiD
¥350
「軽」は軽減税率対象商品です。
クレジットiD支払
伝票番号
端末番号
会員番号
有効期限
承認番号
取引内容
支払区分
支払金額
23611
7704595577901
XXXX XXXX XXXX 6740
XX/XX
売上
一括
¥350
お客様控え
```

#### FullTextAnnotation の中身

`FullTextAnnotation` の中身は `Pages` > `Blocks` > `Paragraphs` > `Words` という階層に分かれます。階層ごとにテキストを連結して出力した結果、各階層では下図のような区切られ方になっていました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/6cef8619-9aa6-1356-1863-1e43af84bbbc.png)

この結果を見る限り、おそらく以下のようなまとめ方をしているのではないかと思います。

- `Pages`：すべての`Blocks`をまとめたもの
  - `Blocks`：座標の近い`Paragraphs`をまとめたもの 
    - `Paragraphs`：意味を考慮して（文単位？）`Words`をまとめたもの
      - `Words`：単語単位

今回は正確な座標が欲しいのと、`Paragraphs` でも少し大き過ぎな感じがあったので、`Words`（＝`TextAnnotations` の２要素目以降）の結果を解析して使うことにしました。

## OpenSiv3D から Cloud Vision API を使う

C# 側は上記サンプルの内容に OBB 情報を追加して出力しています。このプログラムで注意するべき点は、呼び出し元が OpenSiv3D になるため、受け取ったコマンドライン引数を UTF-32 文字列として解釈する必要がある点です。

```csharp:CloudVision.cs
using System.Text;
using Google.Cloud.Vision.V1;

var utf32 = Encoding.UTF32;
var bytes = utf32.GetBytes(args[0]);
var filepath = utf32.GetString(bytes);

var client = ImageAnnotatorClient.Create();

var feature = new Feature
{
    Type = Feature.Types.Type.TextDetection,
    MaxResults = 100,
};
var request = new AnnotateImageRequest()
{
    Image = Image.FromFile(filepath),
    Features = { feature },
};
var response = client.Annotate(request);

Console.WriteLine(response.TextAnnotations.Count - 1);
for (int i = 1; i < response.TextAnnotations.Count; i++)
{
    var annotation = response.TextAnnotations[i];
    var blockVs = annotation.BoundingPoly.Vertices;
    Console.WriteLine(blockVs.Count);
    foreach (var v in blockVs)
    {
        Console.WriteLine(v.X);
        Console.WriteLine(v.Y);
    }

    Console.WriteLine(annotation.Description);
}
```

C++ 側では `ChildProcess` にパスを渡して `CloudVision.exe` を起動します。このときに、コマンドライン引数で画像のパスを与えて、プロセスの入力ストリームから解析結果を受け取ります。

```cpp:Main.cpp
# include <Siv3D.hpp> // Siv3D v0.6.15

constexpr auto VisionExePath = /* ビルドした CloudVision.exe のパス */;

// CloudVision の出力結果を読み取り、単語と OBB ペアの配列を返す
Array<std::pair<String, Array<Vec2>>> ParseData(std::istream& is);

void Main()
{
	Scene::SetBackground(Palette::White);
	Font font(16);

	const auto imagePath = FileSystem::CurrentDirectory() + U"test.jpg"; // 絶対パス
	Texture texture(imagePath);
	Window::Resize(texture.width() * 2, texture.height());

	ChildProcess process(VisionExePath, imagePath, Pipe::StdIn);
	const auto words = ParseData(process.istream());

	while (System::Update())
	{
		texture.draw();

		Transformer2D t(Mat3x2::Translate(texture.width(), 0));

		for (const auto& [word, obb] : words)
		{
			LineString(obb).drawClosed(1, Palette::Orange);
			const auto obbCenter = obb.sum() / obb.size();
			font(word).drawAt(obbCenter, Palette::Black);
		}
	}
}
```

```cpp:Main.cpp
Array<std::pair<String, Array<Vec2>>> ParseData(std::istream& is)
{
	Array<std::pair<String, Array<Vec2>>> result;

	std::string line;
	auto readText = [&](std::istream& is)->String
		{
			std::getline(is, line);
			if (line.ends_with('\r'))
			{
				line.pop_back();
			}
			return Unicode::FromUTF8(line);
		}
	;

	auto readInt = [&](std::istream& is)->int
		{
			return ParseInt<int>(readText(is));
		}
	;

	const auto wordCount = readInt(is);
	for (auto wo : step(wordCount))
	{
		const auto wordVsCount = readInt(is);

		Array<Vec2> wordBBVs;
		for (auto v : step(wordVsCount))
		{
			const auto x = readInt(is);
			const auto y = readInt(is);
			wordBBVs.emplace_back(x, y);
		}

		const auto text = readText(is);
		result.emplace_back(text, wordBBVs);
	}

	return result;
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/d5a65dc0-82a8-b92a-1ddd-d5b7a685a5ae.png)

# 読み取ったテキストを解釈する

単語の配列と OBB を取れるようになったので、これを元にレシートの情報を解析します。

## 正規表現を使った分類
記録したい情報は以下の４つです。単語列から正規表現を使って、大まかに分類することを目指します。
- 店名
- 購入日時
- 商品名
- 値段

### 店名
レシートの先頭から `'店'` で終わる文字列を見つけたら、そこまでを**店名**としてマークします。チェーン店のスーパーやコンビニなら大体このルールに当てはまると思います。正規表現で表すと以下の通りです。

```cpp
const auto reg = UR"([a-zA-Z\p{Katakana}\p{Han}ーｰ\-～~^店]+店)"_re;
```

```mermaid
graph LR
  classDef switchGroup fill:none,stroke:#888,stroke-width:2px,stroke-dasharray: 10 10;
  Start@{ shape: sm-circ }
  End@{ shape: framed-circle }
  Start-->N1@{ shape: fork }
  N1-->A[a-zA-Z] & B[カタカナ] & C[漢字] & D[伸ばし棒] --> N2@{ shape: fork }
  subgraph subA[いずれか一文字]
  A[a-zA-Z] & B[カタカナ] & C['店' 以外の漢字] & D[伸ばし棒]
  end
  N2-->N1
  N2-->'店'
  '店'-->End
  class subA switchGroup;
```

### 購入日時

日付と時刻にはそれぞれ２パターンの書き方があるので、これらの組み合わせをパースできるようにしました。ただし、OCR 結果が完全な状態とは限らないため、日付以降は文字が抜けても部分的にマッチできるようにしています。

- `2024年12月07日(土)` `12時34分`
- `2024/12/07` `12:34`

```cpp
const auto reg = UR"((\d\d\d\d)[年/](\d\d?)[月/](\d\d?)日?\(?[月火水木金土日]?\)?(\d\d)?[時:]?(\d\d)?)"_re;
const auto result = reg.search(U"2024年12月07日(土)12:34");
```

```mermaid
graph LR
  classDef switchGroup fill:none,stroke:#888,stroke-width:2px,stroke-dasharray: 10 10;
  classDef matchGroup stroke-width:3px;
  Start@{ shape: sm-circ }
  Start-->year0
  subgraph subYearA["year = result[1]"]
  year0[0-9]-->year1[0-9]-->year2[0-9]-->year3[0-9]
  end
  subgraph subYearB[いずれか一文字]
  year3-->year4['年'<br>'/']
  end
  year4-->month0
  subgraph subMonthA["month = result[2]"]
  month0[0-9]-->month1[0-9]
  end
  month0-->month2
  subgraph subMonthB[いずれか一文字]
  month1-->month2['月'<br>'/']
  end
  month2-->A@{ shape: comment, label: "続き１" }
  class subYearB switchGroup;
  class subMonthB switchGroup;
  class subYearA matchGroup;
  class subMonthA matchGroup;
```

```mermaid
graph LR
  classDef switchGroup fill:none,stroke:#888,stroke-width:2px,stroke-dasharray: 10 10;
  classDef matchGroup stroke-width:3px;
  A@{ shape: brace-r, label: "続き１" }-->day0
  subgraph subDayA["day = result[3]"]
  day0[0-9]-->day1[0-9]
  end
  day0-->day2
  day1-->day2['日']-->lb["'('"]-->N1
  day0-->N1
  day1-->N1
  day2-->N1@{ shape: fork }
  N1-->week-->N2@{ shape: fork }
  subgraph subWeek[いずれか一文字]
  week['月'<br>'火'<br>'水'<br>'木'<br>'金'<br>'土'<br>'日']
  end
  N1-->N2
  N2-->rb["')'"]
  N2-->N3
  rb-->N3@{ shape: fork }
  N3-->B@{ shape: comment, label: "続き２" }
  class subWeek switchGroup;
  class subDayA matchGroup;
```

```mermaid
graph LR
  classDef switchGroup fill:none,stroke:#888,stroke-width:2px,stroke-dasharray: 10 10;
  classDef matchGroup stroke-width:3px;
  A@{ shape: brace-r, label: "続き２" }-->N1@{ shape: fork }
  N1-->hour0
  subgraph subHourA["hour = result[4]"]
  hour0[0-9]-->hour1[0-9]
  end
  hour1-->N5@{ shape: fork }
  N1-->N5
  N5-->hour2
  subgraph subHourB[いずれか一文字]
  hour2['時'<br>':']
  end
  hour2-->N2@{ shape: fork }
  N5-->N2
  N2-->minute0
  subgraph subMinute["minute = result[5]"]
  minute0[0-9]-->minute1[0-9]
  end
  minute1-->N4@{ shape: fork }
  N2-->N4
  N4-->End@{ shape: framed-circle }
  class subHourB switchGroup;
  class subHourA matchGroup;
  class subMinute matchGroup;
```

### 値段
値段の表記は、自分が確認した範囲だと下記パターンのいずれかでした。
- `¥` `数字`
- `*` `数字`（セブンイレブンのみ）
- `数字`

`¥`か`*`直後の数字はそのまま**値段**として扱えますが、単体で現れる数字もすべて含めてしまうと誤検出が多くなります。したがって、数字単体の場合は条件を追加して、
- 配列上の順番が**日付**よりも後ろにある
- OBB の左上座標がレシートの AABB 上で6割の位置よりも右にある

の両方を満たす要素のみ**値段**としてマークするようにしました。

### 商品名
商品名には特定の文字が付くわけではないため、正規表現で直接的な式を作るのは難しいです。**値段**をある程度正しく識別できたという前提で、単語を順番に辿って同じ行の中で**値段**よりも手前にある要素はとりあえず**商品名**としてマークする仕様にしました。

### その他
レシートの合計金額よりも後ろに記録したい情報が現れることは基本的にないので、以下のいずれかの文字にマッチしたら、それ以降に付けたマークは誤検出とみなして取り消すようにしました。

```cpp
const auto reg = UR"(計|外税|軽減|税率|対象)"_re;
```

## 画像の回転補正

写真の **解像度が十分でなく** かつ **傾いている** ものを Cloud Vision API に投げると、認識率がガクッと下がるケースがあります。認識率は写真の角度に比例して変化する訳ではなく、安定して読める場合と全然読めない場合に割とはっきり分かれていました。

失敗したケースでは日本語の認識率がかなり下がるものの、数字と記号に関してはロバストに検出できていました。このときの OBB を使用して、全要素の傾きの平均値を計算した結果、レシートの傾きとおおよそ一致していました。そして、計算した角度の分だけ画像を逆向きに回転させてから再度 OCR にかけると、今度は大部分の文字を正しく読み取ることができました。

したがって、角度が付いている場合は API リクエストを２回送ることで比較的安定した認識結果が得られます。最初からレシートの角度を推定したらいい話なのですが、これに関して良い方法を知らないので「リトライボタンを押したら回転補正して再リクエストを投げる」という仕様にしました。

| 元の写真 | そのまま OCR | 回転補正して OCR |
|--|--|--|
| ![IMG_0_5_3349_original.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/86f939d5-286a-f7c1-e2c1-4a252eb1ce6b.png) | ![IMG_0_5_3349_ocr.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/97167b6d-5ec1-9a40-98d4-f5615ddab8a2.png) | ![IMG_0_5_3349_corrected.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/d5454234-646e-fe1c-d408-6d00831c0f86.png) |
| ![IMG_0_5_3350_original.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/6988308a-46b3-d7ec-fbb9-fae0d6c8b183.png) | ![IMG_0_5_3350_ocr.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/96daff16-3d6c-3d49-5a1e-c6ce014bc3be.png) | ![IMG_0_5_3350_corrected.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/5a35b3a4-42f1-fe0b-dc66-7b154b1af725.png) |
| ![IMG_0_5_3351_original.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/fbeb5575-2daf-45ee-e3e4-e462dae61a31.png) | ![IMG_0_5_3351_ocr.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/9159228b-8eb1-97b0-b9e4-ea1d0b9e9a64.png) | ![IMG_0_5_3351_corrected.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/05cf4568-47c4-fd2e-1361-f8ca08d6b689.png) |
| ![IMG_0_5_3352_original.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/a6edb8ff-4ae9-acdd-23fd-4d0cb49ebfdc.png) | ![IMG_0_5_3352_ocr.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/1afc5cbf-91d2-5984-ed71-0732f7d2e2ee.png) | ![IMG_0_5_3352_corrected.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/e99ec977-9b4b-aec7-1fcd-03c0b661f5b9.png) |
| ![IMG_0_5_3353_original.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/a8f7e61a-5717-051d-a766-44cde6bec442.png) | ![IMG_0_5_3353_ocr.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/eb80d9ca-59f5-fb94-0860-8dcc99154890.png) | ![IMG_0_5_3353_corrected.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/ecbc1ebc-d290-57ce-e457-c38d0d2d6abc.png) |
| ![IMG_0_5_3354_original.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/736595fa-9040-0a61-6a51-0b9ad2e86792.png) | ![IMG_0_5_3354_ocr.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/8c866ad0-4fbb-f22c-78e6-4534b626e695.png) | ![IMG_0_5_3354_corrected.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/8e9cd7c6-bd44-b2e4-bc54-67bfa543b9df.png) |
| ![IMG_0_5_3355_original.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/0263dc3c-7c70-afec-8628-0edf8d54da12.png) | ![IMG_0_5_3355_ocr.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/a83498b4-403d-17c8-81ee-c887cc5bc381.png) | ![IMG_0_5_3355_corrected.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/c43703c4-b23c-0af7-274f-c5304a2d6943.png) |

# ツールの工夫

[正規表現を使った分類](#正規表現を使った分類) の手順だけで、必要な情報をすべて読み取れるケースは多くありません。以降では、手作業で修正するために実装したツール部分の説明をします。

## テキスト分類の修正

レシート画像の読み取った各単語に枠線を重ねて分類結果を可視化しました。自動分類が間違っていた場合は、ペイント機能で上書きして修正します。

分類内容を解釈した結果をレシートの隣に表示しています。

![receipt_paint.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/8b80b976-b336-e22e-86bd-f61f9069df7c.gif)

## テキストの修正

読み取った文字が間違っていたり、不要な記号が付いていたりする場合があるので、文字列を `TextBox` で修正できるようにしました。**値段**については、パースした数値の総和を下部に表示して、レシートの合計金額と比較できるようにしています。

右側に実際に保存するデータのエントリを `SimpleTable` でプレビュー表示して、テキストが書き換わるたびに更新しています。

![receipt_ocr_edit.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/e88b6217-cd9e-336a-5916-acbfc37ca9ee.gif)

## CSV エクスポート

保存先の CSV ファイルを月ごとに分けたかったので、**購入日時**の`年`と`月`の値でパスを決めるようにしました。

また、同じデータの二重登録を防ぐために、書き込み先の CSV ファイルの中身を事前に読んで、同じ日に購入した品目のリストもエディターで表示しています。

![receipt_ocr_csv.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100367/a9cf04f9-8957-29a4-577c-e2f5e1b033c5.gif)

# 終わりに
コンピュータビジョンの知識がほとんど無い自分でも、大体欲しかったものを作れたのでとても満足です。

レシートは以下の店舗のものを参考にして作りました。この辺なら割とうまく読み取れるんじゃないかと思います。一方で、飲食店のレシートは独自性の高いデザインが多く、ルールベースで対応するのは大変そうな印象でした。

- セブンイレブン
- ファミリーマート
- ローソン
- ミニストップ
- デイリーヤマザキ
- ライフ
- イオンフードスタイル
- グルメシティ
- 成城石井
- ロピア
- KOHYO
- 阪急オアシス