Amazon Textract APIで表を含んだPDFファイルのテキストとか抽出したい

2023.05.24

Amazon Textract は、スキャンしたドキュメントからテキスト、手書き文字、およびデータを自動的に抽出する機械学習 (ML) サービスです。これは、単純な光学文字認識 (OCR) のレベルにとどまらず、フォームやラベルからデータを識別、理解、および抽出します。

請求書の中身を抽出して金額を自動で計算したり、他のシステムにデータを保存したいといったことをやっているのですが、 請求書がPDFで来るのでOCRを利用してデータを抽出、修正などを行なっています。

現在使用しているものだと今までは表に線がないものをうまく読み取れない傾向があったのですが、

Amazon Textractのコンソールにアクセスしたところ、

テーブルモデルの品質を強化するための更新がリリースされ、テーブル API に新しい要素が追加されました。テーブルのタイトル、概要行、セクションのタイトルとフッターを区別できるようになりました。

とあったので、PDFの表がどのくらいの精度で読み取れるか試してみます。

コンソールのメニューにデモがあり、そのデータを見ると綺麗に読み取っているようです。

使用するAPI

Analyze Document API を使います。

請求書だとAnalyze Expense API(請求書 ID、請求書番号、請求書 #、関連する値 12345 などのデータを請求書と領収書から抽出します) というAPIを使えば良いのですが、

明細の表が複数の種類に分かれている請求書だと全て読み取ってはくれなかったのでAnalyze Document APIを使ってみることにしました。

Analyze Document API は、フォーム、テーブル、クエリ、署名の 4つの機能を備えています。

タイトル 説明
Analyze Document API for Forms キーと値のペア(例:「First Name」に関連付けられた値「Jane Smith」など)のデータを抽出します。また、OCRテクノロジーを使用して、ドキュメントからすべてのテキストと手書き文字を抽出します。
Analyze Document API for Tables 列や行で構成された表形式、または表のデータを抽出します。また、OCRテクノロジーを使用して、ドキュメントからすべてのテキストと手書き文字を抽出します。
Analyze Document API for Queries ドキュメント内の必要な情報を指定し(例:「顧客名は何か?」)、そのデータ(例:「Jane Doe」)を応答の一部として受信する柔軟性を提供します。ドキュメント内のデータの構造や、ドキュメントの異なるフォーマットやバージョン間でのデータのレイアウトのバリエーションを気にする必要はありません。また、OCRテクノロジーを使用して、ドキュメントからすべてのテキストと手書き文字を抽出します。
Analyze Document API for Signatures 任意のドキュメントまたは画像の手書き署名、電子署名、イニシャルを検出する機能を提供します。また、OCRテクノロジーを使用して、ドキュメントからすべてのテキストと手書き文字を抽出します。

PDFの表から抽出したいので、 Analyze Document API for Tables を使っていきます

APIアクション

対応するAPIのアクションはStartDocumentAnalysisです。キーと値のペア、テーブル、選択要素など、検出された項目間の関係性について、入力文書の非同期解析を開始します。

StartDocumentAnalysisは非同期でドキュメントの分析を行います。 同期処理の場合はAnalyzeDocumentを使います、。

同期処理では、JPEGまたはPNG形式の画像ファイルを使用することができ、非同期操作では、PDFやTIFF形式のファイルにも対応しています。

今回はPDFを使うので非同期処理であるStartDocumentAnalysisを使いました

パラメーター

解析に必要なパラメーターは、

タイトル 説明 必須
ClientRequestToken 開始リクエストを識別するために使用するidempotentトークンです。複数のStartDocumentAnalysisリクエストで同じトークンを使用した場合、同じJobIdが返されます。ClientRequestToken を使用して、同じジョブが誤って複数回開始されるのを防止します no
DocumentLocation 処理対象のドキュメントを格納するAmazon S3バケット,オブジェクト名を指定します yes
FeatureTypes 実行する解析の種類のリストです。TABLES | FORMS | QUERIES | SIGNATURES の中から文字列の配列として渡します。 yes
JobTag Amazon SNSトピックに公開される完了通知に含まれるように指定する識別子です。例えば、JobTagを使用して、完了通知が対応するドキュメントのタイプ(税額票や領収書など)を識別することができます no
KMSKeyId 推論結果を暗号化するために使用されるKMSキー。キーIDまたはキーエイリアスのいずれかの形式で指定します no
NotificationChannel Amazon Textractに操作の完了ステータスを公開させたいAmazon SNSのトピックARN no
OutputConfig 出力が自身の定義のバケットに行くかを設定します。デフォルトでは、Amazon TextractはGetDocumentAnalysis操作でアクセスできるように結果を内部に保存します no
QueriesConfig 入力によって決定された、QueriesとそれらのQueriesのエイリアスが含まれます no

※ QueriesConfig に関しては こちら

各クエリには、テキストで質問したい質問と、関連付けたいエイリアスが含まれています。

AWSのコマンドラインツールで実行してみました。

解析したいドキュメントはS3に置きます。

表を抽出したいので、--feature-typesにはTABLESを指定。

aws textract start-document-analysis \
--document-location '{"S3Object":{"Bucket":"<<bucket_name>>","Name":"<<document>>"}}' \
--feature-types '["TABLES"]' \
--region <<region_name>>

応答

APIの応答は以下のようです

タイトル 説明
JobId 検出ジョブの識別子です。その後のGetDocumentAnalysisの呼び出しでジョブを識別するためにJobIdを使用します。JobIdの値は7日間しか有効ではありません
{
    "JobId": "df7cf32ebbd2a5de113535fcf4d921926a701b09b4e7d089f3aebadb41e0712b"
}

GetDocumentAnalysis で文章の解析結果がわかります。

実行例

aws textract get-document-analysis \
    --job-id df7cf32ebbd2a5de113535fcf4d921926a701b09b4e7d089f3aebadb41e0712b \
    --max-results 1000
    --region <<region_name>>

まだ解析のJobが実行中だと

{
    "JobStatus": "IN_PROGRESS",
    "AnalyzeDocumentModelVersion": "1.0"
}

という結果が返ってきます。

成功している場合は、"JobStatus": "SUCCEEDED" が含まれた結果が返ってきます。 JobStatusはIN_PROGRESS | SUCCEEDED | FAILED | PARTIAL_SUCCESSのいずれかが含まれます。

Blocksというキーの中にAnalyzeDocumentで検出・解析される項目が含まれます。

※ Blockについてはこちら

Blockは、Amazon Textractが文書から抽出した個々の要素を表すオブジェクトです。 要素は、テキスト、テーブル、セル、キー-値ペア、署名などのさまざまな種類があります。

以下の主要な属性が含まれていて、

  • BlockType: 要素のタイプを示す文字列値です。例えば、テキストは"LINE"または"WORD"、セルは"CELL"、テーブルは"TABLE"となります。
  • Geometry: 要素の位置と境界ボックス情報を表すオブジェクトです。座標値や幅、高さなどの情報が含まれます。
  • Text: 要素がテキストの場合に、そのテキストの値が含まれます。
  • Confidence: 要素が正しく抽出されたという確信度を示す浮動小数点数値です。値の範囲は0から100で、100に近いほど高い信頼性を持つことを意味します。
  • Relationships: 要素と他の要素との関係を示すオブジェクトです。例えば、テーブルのセルと行との関係などを表現します。

抽出された文書要素をより詳細に理解するために使用します。

得られた結果から表の情報をどうやって使う?

今回は、PDFの表の情報を読み取れるか試したかったので、APIから得られた結果からその情報を抜き出すにはどうすればいいの試行錯誤しました。

読み取りに使用したサンプルは以下です。

ちょっとみにくいかと思いますが、表になっているのは3つありますね。

jqを使ってJSONから情報を取得してみます。

BlockType が TABLE のものを取得

表は、2つ以上の行または列を持つグリッドベースの情報であり、セルのスパンはそれぞれ1行1列である。

ドキュメントにはこのように書かれているので、まずはBlockType = TABLEで検索してみました。

cat analyzeDocResponse.json | jq '[.Blocks[] | select(.BlockType == "TABLE")]'

を実行して、以下のような結果を得ることができました。

[
  {
    "BlockType": "TABLE",
    "Confidence": 100,
    "Geometry": {
      "BoundingBox": {
        "Width": 0.8341670036315918,
        "Height": 0.2069982886314392,
        "Left": 0.0768180564045906,
        "Top": 0.41503480076789856
      },
      "Polygon": [
        {
          "X": 0.0768180564045906,
          "Y": 0.41521644592285156
        },
        {
          "X": 0.9107589721679688,
          "Y": 0.41503480076789856
        },
        {
          "X": 0.9109851121902466,
          "Y": 0.6218864917755127
        },
        {
          "X": 0.07698603719472885,
          "Y": 0.6220331192016602
        }
      ]
    },
    "Id": "5cad4b02-8d38-4e40-91e7-2b539a30c5a0",
    "Relationships": [
      {
        "Type": "CHILD",
        "Ids": [
          "5011bada-1a07-4cff-9a67-f7cae5f131cb",
          〜〜〜〜〜 <<略>> 〜〜〜〜
          "87ea458d-ab29-414c-a909-e214583e9ac9"
        ]
      },
      {
        "Type": "TABLE_TITLE",
        "Ids": [
          "70a5a4cd-d8da-42b1-8e51-f857c441843a"
        ]
      }
    ],
    "EntityTypes": [
      "STRUCTURED_TABLE"
    ],
    "Page": 1
  }
・
・
<<以下略>>
]

EntityTypesSTRUCTURED_TABLE(各行の内容がヘッダーに対応する、カラムヘッダーを持つテーブルを識別する) となっていますね

Relationshipsはテーブルのセルと行との関係などを表現するためのオブジェクトなので、ここに含まれているIDにアクセスしていくとテーブルの情報が取得できるはず。

Relationshipsからテーブルの要素を調べていく

ここからが大変そう。

TABLE_TITLE

まずは TABLE_TITLEを取得してみます。

RelationshipsのTypeがTABLE_TITLEで検索

cat analyzeDocResponse.json | jq '[.Blocks[] | select(.BlockType == "TABLE").Relationships[] | select(.Type == "TABLE_TITLE")]'
>>>
[
  {
    "Type": "TABLE_TITLE",
    "Ids": [
      "70a5a4cd-d8da-42b1-8e51-f857c441843a"
    ]
  },
  {
    "Type": "TABLE_TITLE",
    "Ids": [
      "ba832938-390a-4a64-934a-76a958895faa"
    ]
  },
  {
    "Type": "TABLE_TITLE",
    "Ids": [
      "ed68fa37-02a4-44eb-8cf3-b95e7537471c"
    ]
  }
]

取得できたIDでさらに検索.

$ cat analyzeDocResponse.json | jq '[.Blocks[] | select(.Id == "<<取得できたID>>")]'
>>>
[
  {
    "BlockType": "TABLE_TITLE",
    "Confidence": 98.974609375,
    "Geometry": {
      "BoundingBox": {
        "Width": 0.15459120273590088,
        "Height": 0.009709828533232212,
        "Left": 0.3962596654891968,
        "Top": 0.3935844898223877
      },
      "Polygon": [
        {
          "X": 0.3962596654891968,
          "Y": 0.3936188220977783
        },
        {
          "X": 0.5508414506912231,
          "Y": 0.3935844898223877
        },
        {
          "X": 0.5508508682250977,
          "Y": 0.4032602608203888
        },
        {
          "X": 0.3962685763835907,
          "Y": 0.40329432487487793
        }
      ]
    },
    "Id": "70a5a4cd-d8da-42b1-8e51-f857c441843a",
    "Relationships": [
      {
        "Type": "CHILD",
        "Ids": [
          "765a52c9-cda1-4b63-8730-f9c54391fe1b",
          "5ffeeafa-2acd-4549-a445-e18eee5ae350"
        ]
      }
    ],
    "Page": 1
  }
]

テキストの内容がまだ含まれていませんでした。 Relationshipsがあるので、さらにIDで検索。

cat analyzeDocResponse.json | jq '[.Blocks[] | select(.Id == "765a52c9-cda1-4b63-8730-f9c54391fe1b")]'
>>>
[
  {
    "BlockType": "WORD",
    "Confidence": 99.89582061767578,
    "Text": "CHARGE",
    "TextType": "PRINTED",
    "Geometry": {
      "BoundingBox": {
        "Width": 0.06513163447380066,
        "Height": 0.010009308345615864,
        "Left": 0.3967556655406952,
        "Top": 0.393778920173645
      },
      "Polygon": [
        {
          "X": 0.3967556655406952,
          "Y": 0.39379340410232544
        },
        {
          "X": 0.46187788248062134,
          "Y": 0.393778920173645
        },
        {
          "X": 0.46188730001449585,
          "Y": 0.4037739038467407
        },
        {
          "X": 0.3967648446559906,
          "Y": 0.4037882387638092
        }
      ]
    },
    "Id": "765a52c9-cda1-4b63-8730-f9c54391fe1b",
    "Page": 1
  }
]

ここでBlockTypeがWORDの情報を取得できました。

WORDは、

文書ページで検出される単語。単語とは、スペースで区切られていない1つまたは複数のISO基本欧文字のことです。

"Text": "CHARGE", も含まれており、このTextの値が読み取った内容となります。

今回読み取ったPDFの表に、CHARGE SUMMARY という文字列があったので、ここをTABLE_TITLEと認識して読み取っているようですね。正しい。

CELL(Table Header)

検出されたテーブルの中のセルを調べてみます。セルは、セル内のテキストを含むブロックの親。

"BlockType": "TABLE" で取得したデータのRelationshipsから検索していきます。  TypeはChildのものですね。

cat analyzeDocResponse.json | jq '[.Blocks[] | select(.BlockType == "TABLE").Relationships[] | select(.Type == "CHILD")]'
[
  {
    "Type": "CHILD",
    "Ids": [
      "5011bada-1a07-4cff-9a67-f7cae5f131cb",
      ~~~ <<省略>> ~~~
      "87ea458d-ab29-414c-a909-e214583e9ac9"
    ]
  },
~~~ <<省略>> ~~~

Idsに含まれているIDで検索していきましょう。

cat analyzeDocResponse.json | jq '[.Blocks[] | select(.Id == "<< 取得したID >>")]'
>>>
[
  {
    "BlockType": "CELL",
    "Confidence": 92.919921875,
    "RowIndex": 1,
    "ColumnIndex": 1,
    "RowSpan": 1,
    "ColumnSpan": 1,
    "Geometry": {
      "BoundingBox": {
        "Width": 0.13419321179389954,
        "Height": 0.013232359662652016,
        "Left": 0.0768180564045906,
        "Top": 0.4151872396469116
      },
      "Polygon": [
        {
          "X": 0.0768180564045906,
          "Y": 0.41521644592285156
        },
        {
          "X": 0.21099995076656342,
          "Y": 0.4151872396469116
        },
        {
          "X": 0.21101126074790955,
          "Y": 0.4283907115459442
        },
        {
          "X": 0.07682877779006958,
          "Y": 0.4284195899963379
        }
      ]
    },
    "Id": "5011bada-1a07-4cff-9a67-f7cae5f131cb",
    "Relationships": [
      {
        "Type": "CHILD",
        "Ids": [
          "e5329b89-e8b3-445f-9a7d-848a07100e02",
          "76ee371a-f819-4ef3-ba23-ddf06f7042de"
        ]
      }
    ],
    "EntityTypes": [
      "COLUMN_HEADER"
    ],
    "Page": 1
  }
]

EntityTypesが COLUMN_HEADER になっているものは表のヘッダー行です。

TABLE_TITLEの時と同じく、RelationshipsCHILDに含まれいているIDを検索してみます。

cat analyzeDocResponse.json | jq '[.Blocks[] | select(.Id == "<< 取得したID >>")]'
>>>
[
  {
    "BlockType": "WORD",
    "Confidence": 99.55479431152344,
    "Text": "Charge",
    "TextType": "PRINTED",
    "Geometry": {
      "BoundingBox": {
        "Width": 0.03975925222039223,
        "Height": 0.00949832983314991,
        "Left": 0.10757219046354294,
        "Top": 0.41833633184432983
      },
      "Polygon": [
        {
          "X": 0.10757219046354294,
          "Y": 0.4183449447154999
        },
        {
          "X": 0.14732350409030914,
          "Y": 0.41833633184432983
        },
        {
          "X": 0.14733144640922546,
          "Y": 0.4278261065483093
        },
        {
          "X": 0.10757999867200851,
          "Y": 0.4278346598148346
        }
      ]
    },
    "Id": "e5329b89-e8b3-445f-9a7d-848a07100e02",
    "Page": 1
  }
]

BlockTypeがWORDの情報を取得できました。

Textにも読み取った内容が入ってますね。

CELL(Table Contents)

ヘッダー行と同じく、表のコンテンツ内容も同じように取得できそうでした。

cat analyzeDocResponse.json | jq '[.Blocks[] | select(.Id == "bc8c56a9-f2f7-4bac-a6c7-b5a03db38e16")]'
>>>
[
  {
    "BlockType": "CELL",
    "Confidence": 91.30859375,
    "RowIndex": 2,
    "ColumnIndex": 3,
    "RowSpan": 1,
    "ColumnSpan": 1,
    "Geometry": {
      "BoundingBox": {
        "Width": 0.16922445595264435,
        "Height": 0.02471512369811535,
        "Left": 0.4742835462093353,
        "Top": 0.42829766869544983
      },
      "Polygon": [
        {
          "X": 0.4742835462093353,
          "Y": 0.4283340871334076
        },
        {
          "X": 0.6434832811355591,
          "Y": 0.42829766869544983
        },
        {
          "X": 0.6435080170631409,
          "Y": 0.4529772400856018
        },
        {
          "X": 0.4743069112300873,
          "Y": 0.4530127942562103
        }
      ]
    },
    "Id": "bc8c56a9-f2f7-4bac-a6c7-b5a03db38e16",
    "Relationships": [
      {
        "Type": "CHILD",
        "Ids": [
          "f9a867b6-cd74-4735-9e7f-7b9c1c85eef2"
        ]
      }
    ],
    "Page": 1
  }
]

BlockTypeCELLで、 

RowIndex, ColumnIndex, RowSpan, ColumnSpan などのセルの行番号や列番号でコンテンツかは判断できそうですね。

EntityTypesでは表のコンテンツを表す値はなさそうです。

  • COLUMN_HEADER - 列のヘッダーであるセルを識別する。
  • TABLE_TITLE - テーブル内のタイトルであるセルを識別します。
  • TABLE_SECTION_TITLE - テーブル内のセクションのタイトルであるセルを識別します。セクションのタイトルは、通常、セクションの上の行全体に及ぶセルである。
  • TABLE_FOOTER - テーブルのフッターとなるセルを指定します。
  • TABLE_SUMMARY - 表のサマリーセルを識別する。サマリー・セルは、表の1行、または他の表のサマリー情報を含む追加の小さな表であることがあります。
  • STRUCTURED_TABLE - 各行の内容がヘッダーに対応する、列ヘッダーのあるテーブルを表す。
  • SEMI_STRUCTURED_TABLE - 非構造化テーブルを表します。

ここからRelationshipsCHILDに含まれいているIDを検索すると、実際に読み取れた値を取得できるはず。

cat analyzeDocResponse.json | jq '[.Blocks[] | select(.Id == "f9a867b6-cd74-4735-9e7f-7b9c1c85eef2")]'
>>>
[
  {
    "BlockType": "WORD",
    "Confidence": 98.42693328857422,
    "Text": "03/01/2023-03/31/2023",
    "TextType": "PRINTED",
    "Geometry": {
      "BoundingBox": {
        "Width": 0.12484852224588394,
        "Height": 0.0074906498193740845,
        "Left": 0.4965084195137024,
        "Top": 0.43073296546936035
      },
      "Polygon": [
        {
          "X": 0.4965084195137024,
          "Y": 0.4307597577571869
        },
        {
          "X": 0.6213495135307312,
          "Y": 0.43073296546936035
        },
        {
          "X": 0.6213569045066833,
          "Y": 0.4381970167160034
        },
        {
          "X": 0.49651551246643066,
          "Y": 0.43822363018989563
        }
      ]
    },
    "Id": "f9a867b6-cd74-4735-9e7f-7b9c1c85eef2",
    "Page": 1
  }
]

入ってる。

流れをまとめてみる

APIの結果のJSONを解析することで読み取った内容を取得することはできました。

今回はjqを使って一つ一つ確認して行ったのですが、

  1. BlockType = TABLE を検索
  2. 1で取得した内容のRelationshipsに含まれるIDをさらに検索(TABLE_TITLEやCELL)
  3. "BlockType": "WORD" のものが出るまでRelationshipsに含まれるIDをさらに検索

を繰り返していく方法が確実に読み取った内容を取得できそうな感じです。

自動化などでプログラミングする際は一旦この流れを採用してみようかなと。

※ 専用のライブラリなどがあるかどうかはまだ調べてません