C#でWindows標準のOCRを使って領収書の文字を読む|Windows.Media.Ocr

C#・.NET

領収書の金額や日付を、画像から読み取りたい

経費精算では、領収書を見ながら金額・日付・店名を入力するのが手間です。

スマートフォンで撮った領収書の画像から、金額と日付を読み取ってほしい

画像から文字を読み取るOCRは、クラウドのサービスを使う方法がよく紹介されています。ただ、Windows 10・11には文字認識の機能(Windows.Media.Ocr)が標準で入っていて、C#から使えます。日本語版のWindowsなら、追加のインストールも費用もかかりません。

本記事では、C#からWindows標準のOCRを使って領収書の文字を読み取り、金額・日付・登録番号を拾う方法を紹介します。自作の立替精算システムの、領収書の読み取りで使っている部品です。

立替精算システムで使ったところ

私が自作した立替精算システムでは、導入先ごとに選べるオプション(標準ではオフ)として、明細に領収書の画像を添付したときにWindows OCRで読み取り、金額・日付・登録番号の候補を出せるようにしています。設定によっては、支払先の候補も出します。

立替精算システムの領収書の確認の画面。左に領収書の画像、右にWindows OCRで読み取った金額・日付の候補が並んでいる

領収書を添付したときの確認画面です。右側に、Windows OCRで読み取った金額や日付の候補が、選んだ理由と一緒に並びます。押した候補だけが入力欄に入り、自動では入力しません。

クラウドのOCRは、社内のネットワークやVPNの中だけで使うシステムからは呼べないことがあり、領収書という取引の情報を社外に出すことにもなります。Windows標準のOCRなら、サーバのPCの中だけで読み取れます。

使う準備

Windows.Media.OcrはWindowsの機能(WinRTのAPI)なので、C#から使うには、.csprojの TargetFramework の後ろに、使うWindowsのAPIのバージョンを付けます。Visual Studioのコンソールアプリなら、プロジェクトの.csprojを開いて、TargetFramework を次のようにしてください。

<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>

10.0.19041.0 は、Windows 10(バージョン2004)のAPIを使う、という指定です。どのWindowsで動くかを決めるものではありません。net10.0 や net10.0-windows のままだと、using Windows.Media.Ocr; などの行が「型または名前空間の名前 ‘Windows’ が見つかりませんでした」(CS0246)のエラーになります。

.NET 8 の場合は net8.0-windows10.0.19041.0 です。サンプルコードの1行目の #:property は、.NET 10 で dotnet run ファイル名.cs として実行するときに、同じ指定をするための書き方です。Visual Studioのプロジェクトでは、この行を残すとエラー(CS9298)になるので、消してから.csprojで指定してください。

日本語の読み取りには、Windowsの日本語の言語機能に含まれる文字認識(OCR)の部品が使われます。日本語版のWindowsなら、ふつうは最初から入っています。

C#でWindows標準のOCRを使って領収書を読むサンプルコード

動作確認は Windows 11・.NET 10 です(.NET 8 は、ビルドできることだけ確かめています)。読み取る画像のパスを引数で渡します。無ければ、実行するときのフォルダにある receipt.png を読みます。下の見本の画像を receipt.png という名前で保存すると、同じ結果になります。Visual Studioから実行するときは、実行ファイルのあるフォルダ(bin\Debug\net10.0-windows10.0.19041.0)に置いてください。画像が見つからないときは、探した場所を表示します。

#:property TargetFramework=net10.0-windows10.0.19041.0
using System.Text.RegularExpressions;
using Windows.Globalization;
using Windows.Graphics.Imaging;
using Windows.Media.Ocr;
using Windows.Storage;

// 読み取る画像(引数が無ければ receipt.png)
var path = Path.GetFullPath(args.Length > 0 ? args[0] : "receipt.png");
if (!File.Exists(path))
{
    Console.WriteLine($"画像が見つかりません:{path}");
    return;
}

// 日本語の文字認識エンジンを作る。日本語の OCR の言語機能が入っていないと null になる
var engine = OcrEngine.TryCreateFromLanguage(new Language("ja"))
             ?? OcrEngine.TryCreateFromUserProfileLanguages();
if (engine is null)
{
    Console.WriteLine("文字認識に使える言語がありません。Windows の設定で日本語の言語機能を追加してください。");
    return;
}

Console.WriteLine($"認識エンジンの言語:{engine.RecognizerLanguage.DisplayName}");

// 画像を読み込んで、認識エンジンに渡せる形(SoftwareBitmap)にする
var file = await StorageFile.GetFileFromPathAsync(path);
using var stream = await file.OpenAsync(FileAccessMode.Read);
var decoder = await BitmapDecoder.CreateAsync(stream);
using var bitmap = await decoder.GetSoftwareBitmapAsync(
    BitmapPixelFormat.Bgra8, BitmapAlphaMode.Premultiplied);

// 読み取る
var result = await engine.RecognizeAsync(bitmap);

// result.Text は行を空白でつなげてしまうので、行ごとに改行でつなぐ
var zenbun = string.Join('\n', result.Lines.Select(x => x.Text));
Console.WriteLine("---- 読み取った文字 ----");
Console.WriteLine(zenbun);
Console.WriteLine("------------------------");

// 日本語の文字の間に入った空白を詰める(「領 収 書」→「領収書」)
var tsumeta = Regex.Replace(zenbun,
    @"(?<=[\p{IsHiragana}\p{IsKatakana}\p{IsCJKUnifiedIdeographs}]) +(?=[\p{IsHiragana}\p{IsKatakana}\p{IsCJKUnifiedIdeographs}])", "");

// 金額:「¥」の後ろの数字(いちばん大きいものを合計とみなす)
var kingaku = Regex.Matches(tsumeta, @"[¥\\¥]\s*([0-9]{1,3}(?:\s*,\s*[0-9]{3})+|[0-9]+)")
    .Select(m => long.Parse(Regex.Replace(m.Groups[1].Value, @"[\s,]", "")))
    .DefaultIfEmpty()
    .Max();

// 日付:「2026年10月6日」「2026/10/06」などの西暦
var hiduke = Regex.Match(tsumeta, @"(20[0-9]{2})\s*[/年\.\-]\s*([0-9]{1,2})\s*[/月\.\-]\s*([0-9]{1,2})");

// 登録番号:T + 13 桁
var touroku = Regex.Match(tsumeta, @"T\s*((?:[0-9]\s*){13})");

Console.WriteLine($"金額の候補    :{(kingaku > 0 ? $"{kingaku:N0} 円" : "読めませんでした")}");
Console.WriteLine($"日付の候補    :{(hiduke.Success ? $"{hiduke.Groups[1].Value}/{hiduke.Groups[2].Value}/{hiduke.Groups[3].Value}" : "読めませんでした")}");
Console.WriteLine($"登録番号の候補:{(touroku.Success ? "T" + Regex.Replace(touroku.Groups[1].Value, @"\s", "") : "読めませんでした")}");

読み取らせた領収書の画像(説明のために作った見本)です。

読み取りに使った見本の領収書。サンプルホテル新大阪、金額12,800円、2026年10月6日、登録番号T1234567890123

実行結果

認識エンジンの言語:日本語
---- 読み取った文字 ----
領 収 書
株 式 会 社 サ ン プ ル 商 事 様
\ 12 , 800-
但 し 宿 泊 代 と し て
2026 年 10 月 6 日 上 記 正 に 領 収 い た し ま し た
税 抜 金 額 ( 10 % 対 象 )
消 費 税 等 (10%)
サ ン プ ル ホ テ ル 新 大 阪
大 阪 府 大 阪 市 サ ン プ ル 区 1-2-3
TEL 06-0000-0000
登 録 番 号 T1234567890123
¥ 11 , 637
¥ 1 , 163
------------------------
金額の候補    :12,800 円
日付の候補    :2026/10/6
登録番号の候補:T1234567890123

読み取った文字は、日本語の1文字ずつの間に空白が入り、「¥」が「\」になるなど、そのままでは使いにくい形で返ってきます。また、右端に書いてある税抜金額と消費税の金額は、項目名と同じ行にはならず、最後に別の行として返ってきました。行の並びは、見た目どおりとは限りません。空白を詰めてから正規表現で探すと、金額・日付・登録番号を拾えました。

コードの説明

  • OcrEngine.TryCreateFromLanguage(new Language("ja")) で、日本語の文字認識エンジンを作ります。日本語のOCRの言語機能が入っていないと null になるので、そのときはWindowsの言語設定に任せる TryCreateFromUserProfileLanguages() を試しています。ただし、英語などのエンジンになった場合、日本語はほとんど読めません(英語のエンジンでこの見本を読ませると、日本語の行は出てこず、金額も読めませんでした)。1行目に認識エンジンの言語を出しているのは、これを確かめるためです。
  • StorageFile と BitmapDecoder で画像を読み込み、GetSoftwareBitmapAsync で認識エンジンに渡せる形(SoftwareBitmap)にしています。
  • RecognizeAsync の結果には、行ごとの Lines と、全体をつなげた Text があります。Text は行と行を空白でつなげてしまうので、行の区切りを使いたいときは Lines を改行でつなげます。
  • 日本語は1文字ずつ区切られて返ってくることがあるため、ひらがな・カタカナ・漢字の間の空白を正規表現で詰めています。
  • 金額は「¥」の後ろの数字を探し、いちばん大きいものを合計とみなしています。「¥」は「\」や「¥」に読まれることもあるので、3つとも探しています。いちばん大きいものを選ぶのは、サンプルを短くするための簡単な決め方です。「お預り ¥10,000」のような行があると、そちらを選んでしまいます。また、「1,000円」のように「¥」が付いていない金額は拾いません。
  • 日付は「2026年10月6日」「2026/10/06」「2026.10.6」のような、西暦の年月日を探しています。月や日が正しい値かまでは確かめておらず、和暦(令和8年)や2桁の年(26/10/06)は拾いません。
  • 登録番号は、インボイス制度の適格請求書発行事業者の登録番号で、「T」と13桁の数字です。法人は「T」+法人番号、個人事業者などは「T」+法人番号とは重ならない13桁の番号です。サンプルでは、「T」の後ろに数字が13個(間の空白は許す)続くところを探しています。形を見ているだけなので、実際に登録されている番号かどうかは、国税庁の適格請求書発行事業者公表サイトで確かめる必要があります。

立替精算システムでの工夫

実際の領収書の写真は、見本の画像よりずっと読み取りにくいものです。立替精算システムでは、次のような工夫をしています。

  • 認識エンジンが扱える画像の大きさには上限(OcrEngine.MaxImageDimension)があります。このPC(Windows 11)では10,000で、縦か横がこれを超える画像を渡すと、RecognizeAsync がエラー(ArgumentException)になり、1文字も読めません。ふつうのスマートフォンの写真なら収まりますが、1億画素を超えるような設定で撮った写真は、長辺が1万画素を超えることがあります。そのため、上限を超える画像は、縦横比を保ったまま上限の大きさまで縮小してから渡しています。上限の値は決め打ちせず、OcrEngine.MaxImageDimension から読んでいます。
  • 撮影したときの向きの情報(EXIF)があれば、それに従って読みます。向きの情報が無い写真は、横倒しや上下逆のことがあるので、金額と日付のどちらかが読めなかったときだけ90度ずつ回して読み直し、いちばん読めた向きを採っています。
  • 縮小や回転は、読み取りのための一時的な画像だけに行い、保存する領収書の画像(原本)は変えません。
  • 「お預り」「お釣り」「ポイント」「残高」の行の金額は候補にせず、「合計」の語がある行の金額を優先しています。
  • 日付は和暦(令和)や2桁の年も探し、登録番号は全角の「T」や、数字の間のハイフンも許して読んでいます。
  • 候補は1つに絞らず、選んだ理由を添えて最大3件出し、使う人に選んでもらいます。
// 認識エンジンが扱える大きさまで縮小し、指定の向きに回す
var jougen = OcrEngine.MaxImageDimension;
var chouhen = Math.Max(decoder.PixelWidth, decoder.PixelHeight);
var bairitsu = chouhen <= jougen ? 1.0 : (double)jougen / chouhen;

var transform = new BitmapTransform
{
    ScaledWidth = Math.Max(1, (uint)(decoder.PixelWidth * bairitsu)),
    ScaledHeight = Math.Max(1, (uint)(decoder.PixelHeight * bairitsu)),
    Rotation = kaiten,                                // 0・90・180・270 度
    InterpolationMode = BitmapInterpolationMode.Fant, // 縮小しても文字の輪郭が残りやすい
};

using var bitmap = await decoder.GetSoftwareBitmapAsync(
    BitmapPixelFormat.Bgra8,
    BitmapAlphaMode.Premultiplied,
    transform,
    ExifOrientationMode.RespectExifOrientation,
    ColorManagementMode.ColorManageToSRgb);

注意点

  • 読み取りの精度は高くありません。かすれた印字や手書きは読めないことがあります。私が実物の領収書50枚で測ったときは、上位3つの候補に正しい金額が入ったのが38枚、正しい日付が入ったのが29枚でした。そのため立替精算システムでは、読み取った値を自動では入力せず、候補として出して人が選ぶ形にしています。
  • Windowsの機能なので、Linuxのサーバでは使えません。立替精算システムは、Windowsサービスとして動かしているサーバのPCで読み取っています。Windows Serverでの読み取りは確かめていないので、使う前に OcrEngine.AvailableRecognizerLanguages に日本語が入っているかを確かめてください。
  • 英語版のWindowsなどで日本語の言語機能が入っていない場合は、Windows 11なら「設定」→「時刻と言語」→「言語と地域」で日本語を追加し、日本語の文字認識(OCR)の部品を入れてください。追加にはダウンロードが要り、会社のPCでは管理者の設定で追加できないこともあります(英語版のWindowsでは確かめていません)。

最後に

以上が、C#でWindows標準のOCRを使って領収書の文字を読む方法です。

日本語版のWindowsなら追加のインストールも費用もかからず、社外に画像を出さずに読み取れるのが、Windows標準のOCRのよいところです。精度には限りがあるので、「候補を出して人が選ぶ」使い方にすると、入力の手間を減らしつつ、読み違えた値がそのまま入るのを防げます。

この部品を使っている立替精算システムは、こちらで紹介しています。

PythonでPDFをOCR処理する方法は、こちらで紹介しています。

コメント

タイトルとURLをコピーしました