概要
本記事では、Azure Cognitive Servicesで提供されているForm Recognizerを用いて、請求書の画像ファイルから、請求金額などの読み取りをおこなうWebアプリを構築する手順を説明します。WebアプリケーションフレームワークとしてASP.NET Core 5.0を使用します。
目次
-
前編のおさらい
-
準備
-
Webアプリの開発
-
まとめ
1. 前編のおさらい
本記事では、Form Recognizerを用いて請求書の画像ファイルから請求金額などを読み取り、データベースに格納するWebアプリを構築します。前編ではファイルのアップロードおよびForm Recognizerを用いた請求書の読み取りをおこなう部分を構築しました。後編では読み取り結果のデータベースへの格納と表示をおこなう部分を構築します。
2. 準備
読み取り結果の格納先として用いるSQL Databaseリソースの作成をおこないます。
2.1 SQL Databaseリソースを作成する
(1) Azure Portal(https://portal.azure.com)にアクセスし、[リソースの作成]を選択します。
(2) 検索窓に SQL Database と入力してEnterキーを押し、[作成]をクリックします。
(3) サブスクリプション、リソースグループ、名前、価格レベルなどの各項目を入力して、[確認および作成]をクリックします。また必要であればSQL Serverを新規作成します。後ほどアプリで利用するため、データベース名、サーバ名、サーバ管理者ログイン、パスワードについてメモしておきます。
確認および作成の画面で[作成]をクリックします。
3. Webアプリの開発
前編で構築したWebアプリをベースに、読み取り結果のデータベースへの格納と表示をおこなう部分を構築します。
※ 本記事ではLinux上で開発をおこなっていますので、Windowsなど別OSで開発される場合は、NuGetパッケージのインストール方法などを適宜読み替えてください。
3.1 データベースへのインターフェースを準備する
(1) 必要なツールとパッケージを追加します。端末で以下のコマンドを実行します。
$ dotnet tool install --global dotnet-ef $ dotnet add package Microsoft.EntityFrameworkCore.SqlServer $ dotnet add package Microsoft.EntityFrameworkCore.Design |
(2) appsettings.jsonにデータベースへの接続文字列を記載します。2.1 (3) でメモしたデータベース名、サーバ名、サーバ管理者ログイン、パスワードを用いて接続文字列を記述します。
appsettings.json
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Information" } }, "AzureFormRecognizer": { "EndPoint": "Your Endpoint", "Key": "Your Key" }, "ConnectionStrings": { "FrDbContext": "Server=サーバ名;Database=データベース名;User ID=サーバ管理者ログイン;Password=パスワード;Trusted_Connection=False;MultipleActiveResultSets=true" }, "AllowedHosts": "*" } |
(3) 読み取り結果を格納するデータモデルを作成します。
$ mkdir Models $ touch Models/Invoice.cs |
Models/Invoice.cs
using System; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Collections.Generic;
namespace FormRecognizer.Models { public class Invoice { public Guid ID { get; set; }
[DisplayFormat(DataFormatString = "{0:yyyy/MM/dd HH:mm:ss}")] [Display(Name = "読取日時")] public DateTime RecognizedDate { get; set; }
[Display(Name = "ファイル名")] public string FileName { get; set; }
[Display(Name = "請求金額")] public float Total { get; set; }
[DisplayFormat(DataFormatString = "{0:yyyy/MM/dd}")] [Display(Name = "発行日")] public DateTime InvoiceDate { get; set; } } } |
(4) データベースにアクセスするためにDbContext派生クラスを作成します。
$ touch Services/FrDbContext.cs |
Services/FrDbContext.cs
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using FormRecognizer.Models;
namespace FormRecognizer.Services { public class FrDbContext : DbContext { public FrDbContext (DbContextOptions<FrDbContext> options) : base(options) { }
public DbSet<Invoice> Invoice { get; set; } } } |
(5) Startup.csのConfigureServicesにおいて依存性の注入をおこないます。
Startup.cs
~省略~ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using FormRecognizer.Services; using Microsoft.EntityFrameworkCore;
namespace FormRecognizer { ~省略~ public void ConfigureServices(IServiceCollection services) { services.AddRazorPages(); services.AddScoped<IRecognizer, Recognizer>();
services.AddDbContext<FrDbContext>(options => options.UseSqlServer(Configuration.GetConnectionString("FrDbContext"))); } ~省略~ |
(6) Migrationを実行します。
$ dotnet ef migrations add init $ dotnet ef database update |
※この操作を実行するとInvoiceクラスの定義に従ってSQL Database内にテーブルが作成されます。
3.2 Recognizerを更新する
Services/Recognizer.cs
using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Configuration; using Azure; using Azure.AI.FormRecognizer; using Azure.AI.FormRecognizer.Models; using Microsoft.AspNetCore.Http; using System; using System.IO; using System.Linq; using System.Text.RegularExpressions; using FormRecognizer.Models;
namespace FormRecognizer.Services { public interface IRecognizer { Task<Invoice> RecognizeFromFile(IFormFile formFile); }
public class Recognizer : IRecognizer { private readonly ILogger<Recognizer> _logger; private readonly FormRecognizerClient _formRecognizerClient;
public Recognizer(ILogger<Recognizer> logger, IConfiguration configuration) { _logger = logger;
// appsettings.jsonからFormRecognizerの資格情報を取得する var configs = configuration.GetSection("AzureFormRecognizer"); var credential = new AzureKeyCredential(configs["Key"]);
// クライアントを認証する _formRecognizerClient = new FormRecognizerClient(new Uri(configs["EndPoint"]), credential); }
public async Task<Invoice> RecognizeFromFile(IFormFile formFile) { // アップロードされたファイルをMemoryStreamにコピーする using var stream = new MemoryStream(); await formFile.CopyToAsync(stream); stream.Position = 0;
// 請求書読み取りを実行する var options = new RecognizeInvoicesOptions() { Locale = "en-US" }; RecognizeInvoicesOperation operation = await _formRecognizerClient.StartRecognizeInvoicesAsync(stream, options); Response<RecognizedFormCollection> operationResponse = await operation.WaitForCompletionAsync(); RecognizedFormCollection invoices = operationResponse.Value;
// 読み取り結果を処理する RecognizedForm invoice = invoices.Single(); var outInvoice = new Invoice();
_logger.LogInformation($"Invoice File : '{formFile.FileName}'"); outInvoice.FileName = formFile.FileName; outInvoice.RecognizedDate = DateTime.Now;
// 請求金額 if (invoice.Fields.TryGetValue("InvoiceTotal", out FormField invoiceTotalField)) { if (invoiceTotalField.Value.ValueType == FieldValueType.Float) { float invoiceTotal = invoiceTotalField.Value.AsFloat(); _logger.LogInformation($"Invoice Total: '{invoiceTotal}', with confidence {invoiceTotalField.Confidence}"); outInvoice.Total = invoiceTotal; } }
// 発行日 if (invoice.Fields.TryGetValue("InvoiceDate", out FormField invoiceDateField)) { if (invoiceDateField.Value.ValueType == FieldValueType.Date) { DateTime invoiceDate = new DateTime(); try { invoiceDate = invoiceDateField.Value.AsDate(); } catch { string tmpDate = invoiceDateField.ValueData.Text; tmpDate = tmpDate.Replace(" ", ""); tmpDate = Regex.Replace(tmpDate, "[年月⽉]", "/"); tmpDate = Regex.Replace(tmpDate, "[⽇日]", ""); _logger.LogDebug($"{tmpDate}"); invoiceDate = DateTime.Parse(tmpDate); } _logger.LogInformation($"Invoice Date: '{invoiceDate}', with confidence {invoiceDateField.Confidence}"); outInvoice.InvoiceDate = invoiceDate; } }
return outInvoice; } } } |
3.3 ページの設定をおこなう
(1) ページネーション用のクラスを追加します。
$ touch Pages/Paginated.cs |
Pages/Paginated.cs
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore;
namespace FormRecognizer.Pages { public class Paginated<T> : List<T> { public int PageIndex { get; private set; } public int TotalPages { get; private set; }
public Paginated(List<T> items, int pageIndex, int totalPages) { PageIndex = pageIndex; TotalPages = totalPages; this.AddRange(items); }
public string PrevDisabled { get { return PageIndex > 1 ? "" : "disabled"; } } public string NextDisabled { get { return PageIndex < TotalPages ? "" : "disabled"; } }
public static async Task<Paginated<T>> CreateAsync(IQueryable<T> source, int pageIndex, int pageSize) { var totalCount = await source.CountAsync(); var totalPages = Math.Max(1, (int)Math.Ceiling(totalCount / (double)pageSize)); if (pageIndex < 1) pageIndex = 1; if (pageIndex > totalPages) pageIndex = totalPages;
var items = await source .Skip((pageIndex - 1) * pageSize) .Take(pageSize) .ToListAsync();
return new Paginated<T>(items, pageIndex, totalPages); } } } |
(2) Index.cshtml.csを修正します。
Pages/Index.cshtml.cs
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Logging; using FormRecognizer.Services; using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Http; using FormRecognizer.Models;
namespace FormRecognizer.Pages { public class IndexModel : PageModel { private readonly ILogger<IndexModel> _logger; private readonly IRecognizer _recoginzer; private readonly FrDbContext _context;
[BindProperty(SupportsGet = true)] public string Notice { get; set; } [BindProperty] public SingleFileUpload FileUpload { get; set; } public Paginated<Invoice> ListInvoice { get; set; }
public IndexModel(ILogger<IndexModel> logger, IRecognizer recognizer, FrDbContext context) { _logger = logger; _recoginzer = recognizer; _context = context; }
public async Task OnGetAsync(int pageIndex) { if (!ModelState.IsValid) return; int pageSize = 3;
var invoices = _context.Invoice .OrderByDescending(x => x.RecognizedDate);
ListInvoice = await Paginated<Invoice> .CreateAsync(invoices, pageIndex, pageSize); }
public async Task<IActionResult> OnPostUploadAsync() { if (!ModelState.IsValid) return Page();
Notice = $"{FileUpload.FormFile.FileName} を読み込みました"; var Invoice = await _recoginzer.RecognizeFromFile(FileUpload.FormFile);
_context.Invoice.Add(Invoice); await _context.SaveChangesAsync();
return RedirectToPage("./Index", new { Notice, pageIndex = 1 }); }
public class SingleFileUpload { [Required] [Display(Name = "請求書ファイル")] public IFormFile FormFile { get; set; } } } } |
(3) Index.cshtmlを以下のように変更します。
Pages/Index.cshtml
@page @model IndexModel @{ ViewData["Title"] = "Form Recognizer"; }
<div class="row"> <div class="col-1"></div> <div class="col-3"> <form enctype="multipart/form-data" method="post"> <label>請求書ファイル</label> <input asp-for="FileUpload.FormFile" type="file"> <span asp-validation-for="FileUpload.FormFile"></span> <button type="submit" asp-page-handler="upload" class="btn btn-primary btn-block mt-1">Upload</button> </form> </div> <div class="col-7"> <p class="text-danger">@Model.Notice</p> <div style="overflow-y:auto; height:320px"> <table class="table"> <thead> <tr> <th>@Html.DisplayNameFor(model => model.ListInvoice[0].RecognizedDate)</th> <th>@Html.DisplayNameFor(model => model.ListInvoice[0].FileName)</th> <th>@Html.DisplayNameFor(model => model.ListInvoice[0].Total)</th> <th>@Html.DisplayNameFor(model => model.ListInvoice[0].InvoiceDate)</th> </tr> </thead> <tbody> @for(var i = 0; i < Model.ListInvoice.Count; i ++) { <tr> <td>@Html.DisplayFor(modelItem => Model.ListInvoice[i].RecognizedDate)</td> <td>@Html.DisplayFor(modelItem => Model.ListInvoice[i].FileName)</td> <td>@Html.DisplayFor(modelItem => Model.ListInvoice[i].Total)</td> <td>@Html.DisplayFor(modelItem => Model.ListInvoice[i].InvoiceDate)</td> </tr> } </tbody> </table> </div> <a asp-page="./Index" asp-route-pageIndex="@(Model.ListInvoice.PageIndex - 1)" class="btn btn-primary @Model.ListInvoice.PrevDisabled">前へ</a> <span> @Model.ListInvoice.PageIndex / @Model.ListInvoice.TotalPages </span> <a asp-page="./Index" asp-route-pageIndex="@(Model.ListInvoice.PageIndex + 1)" class="btn btn-primary @Model.ListInvoice.NextDisabled">次へ</a> </div> <div class="col-1"></div> </div>
@section Scripts{ @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} } |
3.4 Webアプリを起動してテストする
(1) アプリを起動してブラウザから接続します。接続先URLは https://localhost:5001 です。
$ dotnet run |
(2) ファイル選択ボタンを押して請求書のファイルを選んだ後、Uploadボタンを押すとForm Recognizerによる読み取りがおこなわれます。JPEG、PNG、PDF、TIFFファイルに対応しています。読み取り結果が新しい順に表示されます。
(3) 端末でCtrl+Cを押してアプリを終了します。
3.5 解説
(1) データベースへのインターフェースとしてFrDbContextクラスを定義し、csにおいて依存性の注入をおこなうことで、IndexModelからFrDbContextインスタンスにアクセスできるようにしています。またEntityFrameworkのMigration機能を用いてデータベースにテーブルを作成しています。
依存性の注入について
https://docs.microsoft.com/ja-jp/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-5.0
EntityFrameworkについて
(2) IndexModelでは画像ファイルがPOSTされるとRecognizerのRecognizeFromFileメソッドを呼び出して読み取りを実行し、読み取り結果をFrDbContextインスタンスを通じてデータベースに格納しています。
(3) IndexModelはGETリクエストを受け取ると、データベースに格納されているInvoiceデータを読取日時が新しい順に並び替えたうえでPaginatedクラスを利用してページごとに表示します。
ASP.NET Core ページングについて
https://docs.microsoft.com/ja-jp/aspnet/core/data/ef-rp/sort-filter-page?view=aspnetcore-5.0
4. まとめ
本記事では、Form Recognizerを用いて請求書の画像ファイルから請求金額などを読み取り、データベースに格納するWebアプリを構築しました。執筆時点では、請求書読み取りの事前構築済みモデルは日本語に対応しておらず、日本語の請求書に対する読み取り精度はよくありません。カスタムモデルをトレーニングして利用するなどの対応が必要になりそうです。
~~連載記事一覧~~