77
@shoku-pan

【Google Colab】Vision APIで『レシートOCR』

はじめに

OCR(光学文字認識)とは、活字や手書き文字を読み取って文字データに変換する技術です。

請求書、レシート、名刺、免許証といった様々なものに対して、OCRのサービスが提供されています。
OCRを使用することで、データ入力の手間を少なくすることができます。
また、他のシステムと連携することで、データの有効活用も可能になります。

有名なOCRサービスに、Google Vision API(以下、Vision API)があります。
Vision APIは、Googleが提供する非常に高性能・多機能な画像分析サービスです。
こちらに無料トライアルページがあります。

レシートの画像解析は、自動家計簿アプリなどで活用されています。
レシートを撮影すると、その商品情報や合計金額をOCRで読み取り、データを登録する仕組みです。

今回は、Vision APIを使用して簡単なレシートOCRをやってみました。

※免許証のOCRは、以下をご参照下さい。

レシートOCR

環境

環境はGoogle Colaboratoryを使用します。
Pythonのバージョンは以下です。

import platform
print("python " + platform.python_version())
# python 3.6.9

画像を表示してみよう

では、早速コードを書いていきましょう。
まずは、画像の表示に必要なライブラリをインポートします。

import cv2
import matplotlib.pyplot as plt
%matplotlib inline
import matplotlib

レシートのサンプル画像も用意しておきます。
それでは画像を表示してみましょう。

img = cv2.imread(input_file) # input_fileは画像のパス
plt.figure(figsize=[10,10])
plt.axis('off')
plt.imshow(img[:,:,::-1])

image.png

Vision API セットアップ

では早速、このレシート画像をGoogle Vision APIを使用してOCRをしてみましょう。

Vision APIの料金体系について
・最初の 1,000 ユニット/月は無料(2020/06/21時点)
・料金体系の詳細はこちらを参照ください。

以下、Vision APIを使用するのに必要な準備を行います。
こちらの通りに作業を進めてください。
クライアントライブラリのインストールと、サービスアカウントキーの発行が必要になります。

クライアントライブラリのインストールは以下です。

pip install google-cloud-vision

発行したサービスアカウントキーを使用して、環境変数を設定します。

import os
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = json_path # json_pathは、サービスアカウントキーのパス

APIへリクエスト送信・レスポンス取得

それでは、Vision APIへリクエストを送信し、レスポンスを取得しましょう。

import io

from google.cloud import vision

client = vision.ImageAnnotatorClient()
with io.open(input_file, 'rb') as image_file:
    content = image_file.read()
image = vision.Image(content=content)
response = client.document_text_detection(image=image)

エラーなく実行できれば、無事にAPIへリクエスト送信・レスポンス取得ができています。

このresponseに、Vision APIのOCR結果が入っています。
読み取った文字情報、座標の情報、確信度、言語など様々な情報が入っています。
ここでは、読み取った全文テキストの文字情報を確認してみましょう。

print(response.text_annotations[0].description)
SAVERSONICS
セブン-イレブン
千代田店
東京都千代田区二番町8-8
電話:03-1234-5678
レジ#31
2019年10月01日(火) 08:45責012
領取書
手巻おにぎり辛子明太子
コカコーラ500ml
パラドゥミニネイル PK03
メビウスワン
50円切手
*130
*140
300
490、
50年
小計(税抜8%)
¥270
消費税等(8%)
¥21
小計(税抜10%)
¥300
消費税等(10%)
¥30
小計(税込10%)
¥490
小計(非課税)
¥50
合計 ¥1,161
(税率 8%対象
¥291)
(税率10%対象
¥820)
(内消費税等8%
¥21)
(内消費税等10%
¥74)
キャッシュレス還元額
-22
nanaco支払
¥1,139
お買上明細は上記のとおりです。
nanaco番号
*******9999
今回ポイント
2P
[*]マークは軽減税率対象です。

非常に高い精度で読み取れていることが確認できます。

Vision APIは、文字の集まり具合から、画像をblockごと、paragraphごとなどに分割します。
分割されたそれぞれの領域を確認してみましょう。
まずは関数を定義します(詳細はこちらのコードを参照ください)。

from enum import Enum

class FeatureType(Enum):
    PAGE = 1
    BLOCK = 2
    PARA = 3
    WORD = 4
    SYMBOL = 5

def draw_boxes(input_file, bounds):
    img = cv2.imread(input_file, cv2.IMREAD_COLOR)
    for bound in bounds:
      p1 = (bound.vertices[0].x, bound.vertices[0].y) # top left
      p2 = (bound.vertices[1].x, bound.vertices[1].y) # top right
      p3 = (bound.vertices[2].x, bound.vertices[2].y) # bottom right
      p4 = (bound.vertices[3].x, bound.vertices[3].y) # bottom left
      cv2.line(img, p1, p2, (0, 255, 0), thickness=1, lineType=cv2.LINE_AA)
      cv2.line(img, p2, p3, (0, 255, 0), thickness=1, lineType=cv2.LINE_AA)
      cv2.line(img, p3, p4, (0, 255, 0), thickness=1, lineType=cv2.LINE_AA)
      cv2.line(img, p4, p1, (0, 255, 0), thickness=1, lineType=cv2.LINE_AA)
    return img

def get_document_bounds(response, feature):
    document = response.full_text_annotation
    bounds = []
    for page in document.pages:
        for block in page.blocks:
            for paragraph in block.paragraphs:
                for word in paragraph.words:
                    for symbol in word.symbols:
                        if (feature == FeatureType.SYMBOL):
                          bounds.append(symbol.bounding_box)
                    if (feature == FeatureType.WORD):
                        bounds.append(word.bounding_box)
                if (feature == FeatureType.PARA):
                    bounds.append(paragraph.bounding_box)
            if (feature == FeatureType.BLOCK):
                bounds.append(block.bounding_box)
    return bounds

それでは、画像にそれぞれの領域を書き込んで表示してみましょう。

bounds = get_document_bounds(response, FeatureType.BLOCK)
img_block = draw_boxes(input_file, bounds)

bounds = get_document_bounds(response, FeatureType.PARA)
img_para = draw_boxes(input_file, bounds)

bounds = get_document_bounds(response, FeatureType.WORD)
img_word = draw_boxes(input_file, bounds)

bounds = get_document_bounds(response, FeatureType.SYMBOL)
img_symbol = draw_boxes(input_file, bounds)

plt.figure(figsize=[20,20])
plt.subplot(141);plt.imshow(img_block[:,:,::-1]);plt.title("img_block")
plt.subplot(142);plt.imshow(img_para[:,:,::-1]);plt.title("img_para")
plt.subplot(143);plt.imshow(img_word[:,:,::-1]);plt.title("img_word")
plt.subplot(144);plt.imshow(img_symbol[:,:,::-1]);plt.title("img_symbol")

image.png

画像をblock、paragraph、word、symbolという様々な単位で領域を分割していることが確認できました。

テキスト整形

このように、Vision APIはいい感じに領域を分割してくれるのですが、場合によってはデメリットとなることもあります。
例えば今の場合、「手巻きおにぎり辛子明太子」とその金額「*130」は分かれてしまっています。
レシートの性質上1行ごとに情報がまとまっていることが多いので、行単位で分割することを考えます。

どうすれば、行ごとに分けることができるでしょうか。
Vision APIは、1文字ごとの座標の情報(上記のsymbolのbounding_box)を持っています。
座標の値で、左から右、上から下に並び替えればうまくいきそうです。
以下、文字の座標により行ごとにまとめる処理を作成します。

def get_sorted_lines(response):
    document = response.full_text_annotation
    bounds = []
    for page in document.pages:
      for block in page.blocks:
        for paragraph in block.paragraphs:
          for word in paragraph.words:
            for symbol in word.symbols:
              x = symbol.bounding_box.vertices[0].x
              y = symbol.bounding_box.vertices[0].y
              text = symbol.text
              bounds.append([x, y, text, symbol.bounding_box])
    bounds.sort(key=lambda x: x[1])
    old_y = -1
    line = []
    lines = []
    threshold = 1
    for bound in bounds:
      x = bound[0]
      y = bound[1]
      if old_y == -1:
        old_y = y
      elif old_y-threshold <= y <= old_y+threshold:
        old_y = y
      else:
        old_y = -1
        line.sort(key=lambda x: x[0])
        lines.append(line)
        line = []
      line.append(bound)
    line.sort(key=lambda x: x[0])
    lines.append(line)
    return lines

それでは確認してみましょう。

img = cv2.imread(input_file, cv2.IMREAD_COLOR)

lines = get_sorted_lines(response)
for line in lines:
  texts = [i[2] for i in line]
  texts = ''.join(texts)
  bounds = [i[3] for i in line]
  print(texts)
  for bound in bounds:
    p1 = (bounds[0].vertices[0].x, bounds[0].vertices[0].y)   # top left
    p2 = (bounds[-1].vertices[1].x, bounds[-1].vertices[1].y) # top right
    p3 = (bounds[-1].vertices[2].x, bounds[-1].vertices[2].y) # bottom right
    p4 = (bounds[0].vertices[3].x, bounds[0].vertices[3].y)   # bottom left
    cv2.line(img, p1, p2, (0, 255, 0), thickness=1, lineType=cv2.LINE_AA)
    cv2.line(img, p2, p3, (0, 255, 0), thickness=1, lineType=cv2.LINE_AA)
    cv2.line(img, p3, p4, (0, 255, 0), thickness=1, lineType=cv2.LINE_AA)
    cv2.line(img, p4, p1, (0, 255, 0), thickness=1, lineType=cv2.LINE_AA)

plt.figure(figsize=[10,10])
plt.axis('off')
plt.imshow(img[:,:,::-1]);plt.title("img_by_line")
セブン-イレブン
SAVERSONICS
千代田店
東京都千代田区二番町8-8
電話:03-1234-5678レジ#31
2019年10月01日(火)08:45責012
領取書
手巻おにぎり辛子明太子*130
コカコーラ500ml*140
パラドゥミニネイルPK03300
メビウスワン490、
50円切手50年
小計(税抜8%)¥270
消費税等(8%)
¥21
小計(税抜10%)¥300
消費税等(10%)¥30
小計(税込10%)¥490
小計(非課税)¥50
合計¥1,161
(税率8%対象
¥291)
(税率10%対象¥820)
(内消費税等8%¥21)
(内消費税等10%¥74)
キャッシュレス還元額-22
nanaco支払¥1,139
お買上明細は上記のとおりです。
nanaco番号*******9999
今回ポイント2P
[*]マークは軽減税率対象です。

うまく行ごとにまとめることができました。

テキスト構造化

テキスト整形により、文字列を整理することができました。
これにより、必要な情報が取り出しやすくなりました。
必要な情報を取り出す場合には、正規表現や自然言語処理によるテキスト処理が考えられます。

今回は、正規表現を使用して、電話番号、日付、時刻、合計金額を抽出して構造化しました。

※日付、時刻の正規表現の詳細については、以下をご参照下さい。

import re

def get_matched_string(pattern, string):
    prog = re.compile(pattern)
    result = prog.search(string)
    if result:
        return result.group()
    else:
        return False

pattern_dict = {}
pattern_dict['date'] = r'[12]\d{3}[/\-年](0?[1-9]|1[0-2])[/\-月](0?[1-9]|[12][0-9]|3[01])日?'
pattern_dict['time'] = r'((0?|1)[0-9]|2[0-3])[:時][0-5][0-9]分?'
pattern_dict['tel'] = '0\d{1,3}-\d{1,4}-\d{4}'
pattern_dict['total_price'] = r'合計¥(0|[1-9]\d*|[1-9]\d{0,2}(,\d{3})+)$'

for line in lines:
  texts = [i[2] for i in line]
  texts = ''.join(texts)
  for key, pattern in pattern_dict.items():
    matched_string = get_matched_string(pattern, texts)
    if matched_string:
      print(key, matched_string)

# tel 03-1234-5678
# date 2019年10月01日
# time 08:45
# total_price 合計¥1,161

電話番号、日付、時刻、合計金額を抽出することができました。

まとめ

今回は、Google のVision APIを使用して、レシートOCRを行ってみました。

Vision APIは、非常に高精度なOCR機能を持っています。
また、個人でも気軽に使用できるOCRサービスです。
OCR結果のテキストに対して、正規表現や自然言語処理を適用することで、欲しい情報を抽出することができました。
ぜひ、サービス開発や業務効率化にご活用下さい。

なお、今回ご紹介したGoogle Vision APIとは別に、PythonにはTesseractOCRというOCRライブラリもあります。
こちらの精度(特に日本語)はVision APIに大きく劣る印象ですが、無料でありローカルで使用できます。
こちらについても以下の記事で紹介していますので、興味のある方は是非ご覧ください。

77
ユーザー登録して、Qiitaをもっと便利に使ってみませんか。
  1. あなたにマッチした記事をお届けします
    ユーザーやタグをフォローすることで、あなたが興味を持つ技術分野の情報をまとめてキャッチアップできます
  2. 便利な情報をあとで効率的に読み返せます
    気に入った記事を「ストック」することで、あとからすぐに検索できます
shoku-pan
つよつよエンジニアになりたい/kaggle/画像処理/自然言語処理/TOEIC
asken-inc
ひとびとの明日を今日より健康にする
この記事は以下の記事からリンクされています
過去の1件を表示する

コメント

Vision APIの仕様変更に伴い、以下のコード部分を修正しました。

typesを使用することなく、vision.Imageと直接呼び出すことができるようになりました。

import io

from google.cloud import vision
from google.cloud.vision import types

client = vision.ImageAnnotatorClient()
with io.open(input_file, 'rb') as image_file:
    content = image_file.read()
image = types.Image(content=content)
response = client.document_text_detection(image=image)
import io

from google.cloud import vision

client = vision.ImageAnnotatorClient()
with io.open(input_file, 'rb') as image_file:
    content = image_file.read()
image = vision.Image(content=content)
response = client.document_text_detection(image=image)
0
あなたもコメントしてみませんか :)
ユーザー登録
すでにアカウントを持っている方はログイン
記事投稿イベント開催中
新人プログラマ応援 - みんなで新人を育てよう!
~
Java開発者のためのAzure入門
~