Google Cloud Vision APIで画像メインのPDFから直接OCRする(PDF/TIFF Document Text Detection)


スポンサーリンク

この記事で紹介しているAPIは今のところベータ版です。正式リリースまでに仕様が変化する可能性があります。

件のPDFデータの処理*1のため、Google Cloud Vision APIのOCR機能のうち、PDFおよびTIFF画像を対象にした一括処理モードを試してみました。

新しいモードではなく、従来のDOCUMENT_TEXT_DETECTIONの対応ファイル形式が一括処理モード限定で増えたという感じです。

PDF/TIFF Document Text Detection  |  Cloud Vision API Documentation  |  Google Cloud

いつの間にか追加されており、存在は知っていましたがなんか面倒な雰囲気だったので。

感想としては対象がPDFなら便利、かも。ただ出力形式はfullTextAnotation形式なのでパースがちょっと面倒な印象。APIのバージョンが新しい分、出力は同一ではないです。

注意点など

過去記事でも紹介しているように、Google Cloud Vision APIのOCR機能は指定するパラメーターがTEXT_DETECTIONとDOCUMENT_TEXT_DETECTIONの種類あります。このPDFおよびTIFF形式画像処理モードは後者の方で、処理結果ののJSONもfullTextAnotationキーの形式です。

つまり、文書の構造を解析した上で、ページ、ブロック、文章、文字、という階層構造で結果が返ってきます。パースがちょっとめんどいです*2。注意点は座標情報が正規化された数値(最大値が1?)になっている点。対象ファイル形式がPDF/TIFF形式だからというよりは、APIのバージョンの関係だと思いますが。

なお、TIFF形式もPDF同様に複数ページを1ファイルに含めることが可能なファイルフォーマットです*3。この記事ではPDFのOCRしか試していません。

a244.hateblo.jp

この機能は現状ではあくまでもベータ版です。料金体系は既存のAPIの、複数画像一括モードと同じとのこと。

Googleのサンプルコードについて

下記のサンプルコードは90秒以内に処理が完了しないとタイムアウトおよび処理未完了の例外が発生して終了する仕様なので不便。これをベースに改造してみます。

python-docs-samples/detect_pdf.py at master · GoogleCloudPlatform/python-docs-samples · GitHub

パラメーターが決めうちになっていて一度に2ページずつOCR処理が実行される。タイムアウトの値が90秒となっている。

そのままでもそれなりに役に立つ。実際に使うなら以下のパラメータを適当に修正する。

  • batch_size = 2
  • timeout=90

ページ数が多いと時間内に終わらないのでスクリプト自体は異常終了する。ただし、一度リクエストを受け付けてしまえば別に途中でプログラムが落ちても処理は続行されるので問題はない。

なお、他にもGoogle Cloud Vision APIのPythonのサンプルコードはいろいろあります。

準備

  • Google Cloud Vision APIを有効化
  • APIキーの入手
  • Google Storageにファイルを配置するバケットを確保
  • ファイルをGoogle Storageにアップロード

APIの有効化とかその辺は適当にググって下さい。

環境

  • Python 3.6.5
  • google-cloud-vision 0.31.1

改造するサンプルを入手

以下のようにしてサンプルコードを入手。

$ wget https://raw.githubusercontent.com/GoogleCloudPlatform/python-docs-samples/master/vision/cloud-client/detect/detect_pdf.py

Google Storageに出力先を確保

Cloud Vision APIを有効にしたプロジェクトを選択した状態でバケットを作成する。

ストレージ バケットの作成  |  Cloud Storage ドキュメント  |  Google Cloud

ここに対象のPDFをアップロード。完了したら「一般公開で共有する」というカラムのチェックボックスをオン。

バケット名とオブジェクト名(ファイル名)を確認しておく。

ライブラリのインストール

$ pip3 install --upgrade google-cloud-vision google-cloud-core

バージョンが古いとベータ版のAPIを実行できないので注意。

改造したプログラム

OCR処理が完了するまでスリープし続けるように修正したPYthonスクリプト

処理の完了待ちをresult()メソッドからdone()の戻り値をチェックする方式に変更している。あとはメソッドの分割。

ハードコードしているバッチサイズ(batch_size = 2)を2から5に変更。

まれにJSONのパースに失敗して例外を投げて終了することがありますが、ちゃんと調べていません。すいません。

実行

クレデンシャル(APIキーの記載されたファイル)のパスを環境変数にセットしておく。direnvを使うと管理がラクでいいのでおすすめ。

$ export GOOGLE_APPLICATION_CREDENTIALS="/path/to/api.json"

参考:いまさら direnv の解説

実行例

基本的な使い方は改造前と同じ。まず作成したバケットにPDFファイルまたはTIFFファイルを配置。引数にGoogle Storageのファイルのパスと、出力先のパスを指定して実行。

$ python3 ocr_and_wait.py --gcs-source-uri gs://<bucket-name>/input.pdf --gcs-destination-uri gs://<bucket-name>/ocr_output/

出力先については末尾のスラッシュが無いとフォルダ名扱いされず、ファイル名の接頭辞扱いになるので注意する。また、出力先のフォルダが存在しない場合は自動的に作成される。

出力ファイル名はoutput-1-to-5.jsonのような開始ページ番号と出力ページ番号を含む形式。ソートしにくい。

JSONのパース

JSONからページ全体の認識結果を取り出すには以下のようにする。

import json

filename = "output-1-to-5.json"
fp = open(filename)
data = json.load(fp)
data['responses'][0]['fullTextAnnotation']['text']

出力のJSONから各ページのそれぞれの文字にアクセスするには、以下のように階層をたどる。

data['responses'][0]['fullTextAnnotation']['pages'][0]['blocks'][0]['paragraphs'][0]['words'][1]['symbols'][0]['text']

もしくはGoogleのサンプルコードがやっているように、json_format.Parse()を使用してパースする。

from google.cloud import vision_v1p2beta1 as vision
from google.protobuf import json_format

json_str = open(filename).read()
response = json_format.Parse(json_str, vision.types.AnnotateFileResponse())

response.responses[0].full_text_annotation.text

同じように、ドキュメント構造を考慮してテキストをたどることもできる。認識したテキストの位置情報や確からしさも取得できる。

response.responses[0].full_text_annotation.pages[0].blocks[0].paragraphs[0].words[0].symbols[0].text  # 出力は省略

以下のように言語や位置の情報も取得できる。

response.responses[0].full_text_annotation.pages[0].blocks[0].paragraphs[0].words[0].symbols[0].property  # 検出した言語
detected_languages {
  language_code: "ja"
}

行末や空白も情報としては取得できる。

response.responses[0].full_text_annotation.pages[0].blocks[0].paragraphs[0].words[0].symbols[1].property
detected_break {
  type: SPACE
}
response.responses[0].full_text_annotation.pages[0].blocks[0].paragraphs[0].words[0].bounding_box
normalized_vertices {
  x: 0.1596638709306717
  y: 0.026128266006708145
}
normalized_vertices {
  x: 0.2218487411737442
  y: 0.027315914630889893
}
normalized_vertices {
  x: 0.2218487411737442
  y: 0.04156769439578056
}
normalized_vertices {
  x: 0.1596638709306717
  y: 0.04156769439578056
}

Symbolクラスの要素(symbolsプロパティ以下の配列の値)にもbounding_boxというプロパティはあるけど値はセットされていない。

response.responses[0].full_text_annotation.pages[0].blocks[0].paragraphs[0].words[0].symbols[0].confidence  # 確からしさ
0.9800000190734863

JSONを自力でパースするよりはGoogleのライブラリを使うほうが少しは楽か?。

そのほか

JSONファイルの分割など

例えば、複数ページ1ファイルなので分割したい時とか。

バッチサイズを1にすればいいだけな気もするけど、すでに処理済みのデータが有る場合の話。

JSONを読み込んでページごとに分割するとして、Googleのサンプルコードのようにjson_format.Parse()でデータを読み込んだ場合、そのままではJSONに戻せない。

読み込んだデータはgoogle.cloud.vision_v1p2beta1.types.AnnotateFileResponseクラスのオブジェクトになっている。

JSONに戻したい場合は、google.protobuf.json_formatモジュールに含まれるMessageToJsonという関数を使う。

はじめから普通にJSONとしてロードすればこういう面倒事は発生しない……。

JSONを分割するスクリプト

以下、参考。

#! /usr/bin/env python3
# encoding: utf-8

from google.cloud import vision_v1p2beta1 as vision
from google.protobuf import json_format
from google.protobuf.json_format import MessageToJson

import json
import pathlib
from natsort import natsorted

## JSONファイルのあるパスを指定
#pl = pathlib.Path("./from_ministry-of_finace/output")
pl = pathlib.Path("./masked/from_ministry-of_finace/index_data/")

filelist = list(pl.glob('*.json'))

for file_path in filelist:
    print(file_path)


    with file_path.open(mode='rt',encoding='utf-8') as jfp:
        str = jfp.read()

        response = json_format.Parse(str, vision.types.AnnotateFileResponse())

        for i, res in enumerate(response.responses) :
            page_num = res.context.page_number

            print("{0} {1} {2}".format(file_path.name, i,page_num ) )

            filename = "output_json_{0}.json".format(page_num)


            ## 出力先のパスを指定
            #output_path = pathlib.Path("ocr_result_masked", filename)
            output_path = pathlib.Path("./index_output", filename)

            with output_path.open(mode='w',encoding='utf-8') as output:

                serialized = MessageToJson(res)

                temp = json.loads(serialized)
                json.dump(temp, output, ensure_ascii=False, indent=2, sort_keys=True, separators=(',', ': '))
                #output.write(serialized)

ブロック単位でOCR結果を表示する例

あまりスマートではないですが参考程度に。

for block in json_data['fullTextAnnotation']['pages'][0]['blocks'] :
    print(block['boundingBox'])
    print(block['blockType'])

    for pg in block['paragraphs'] :
       words = [ w for w in pg['words'] ]
       symbols = []
       for w in words :
           symbols = [ s for s in w['symbols'] ]

            text = []
            for s in symbols :
               text.append(s['text'])
               if "detectedBreak" in s["property"] :
                   node = s["property"]['detectedBreak']
                   if node['type'].startswith('EOL_SURE_SPACE'):
                      text.append("\n")
                   elif node['type'].endswith('SPACE') :
                      text.append(" ")
                   else:
                      text.append("\n")

            print("".join(text), end='')
        print("")

貼り付けるときにミスってインデントがおかしい可能性が微レ存。

参考URL

リクエストとレスポンスに関しては 以下のドキュメントを参照。

まとめ

妙に長い記事になってしまった。ものすごく書きぶりが変な感じだけどまあいいか。

下書き状態で貯め込むよりはいいでしょうってことで。

他の記事も含めて要書き直し。

*1:陸自じゃなくて財務省の方。

*2:多重ループになるのと、空白や改行の場合は処理を分ける必要がある

*3:正直に言うとTesseract の学習時にもマルチページのTIFFファイルが生成されるまで知らなかった

広告