はじめに
Amazon Textract は AWS 上で使える AI による画像文字認識、いわゆる OCR サービスです
ただし、 Textract は単に文字を認識するだけでなく、表や入力フォームなどの構造を認識し、意味のある塊でデータを読み込むことができます
ブラウザからお手軽に試す場合、以下の記事を参考にしてください
日本語には未対応であるため、英数字のデータしか認識できません
本記事では Elixir の Livebook から Textract を使い、画像からの表データ読込を実装します
実装したノートブックはこちら
Livebook とは
Livebook は Elixir のコードをブラウザから実行し、結果を表示してくれるツールです
Python における Jupyter のようなもので、 Elixir 入門や Elixir を使ったデータ分析、データの視覚化などに適しています
はじめ方は以下の記事を参考にしてください
セットアップ
Livebook で新しいノートブックを開いたら、先頭のセットアップセルに以下のコードを入力し、実行してください
Mix.install([
{:aws, "~> 0.13"},
{:hackney, "~> 1.20"},
{:kino, "~> 0.11"}
])
AWS サービスの操作用に AWS Elixir というモジュールをインストールしています
クライアントの準備
AWS の認証情報を入力するためのテキストボックスを準備します
access_key_id_input = Kino.Input.password("ACCESS_KEY_ID")
secret_access_key_input = Kino.Input.password("SECRET_ACCESS_KEY")
region_input = Kino.Input.text("REGION")
[
access_key_id_input,
secret_access_key_input,
region_input
]
|> Kino.Layout.grid(columns: 3)
セルを実行して表示されたテキストボックスにそれぞれ入力してください
Textract は東京リージョンを使用できないため、リージョンはバージニア北部(us-east-1)などを指定してください
AWS の API と通信するためのクライアントを用意します
この際、先ほどの認証情報を受け渡します
client =
AWS.Client.create(
Kino.Input.read(access_key_id_input),
Kino.Input.read(secret_access_key_input),
Kino.Input.read(region_input)
)
画像からの表データ読込
以下の画像(Amazon Polly のときに撮ったスクリーンショット)を対象として、画像内の表データを読み取ってみます
まず、画像入力を用意します
image_input = Kino.Input.image("IMAGE", format: :png)
表示されたフォームに画像をドラッグ&ドロップ、もしくは Upload ボタンから画像を選択すると、画像が表示されます
画像を Textract に送るため、バイナリデータとして読み込みます
image_binary =
image_input
|> Kino.Input.read()
|> Map.get(:file_ref)
|> Kino.Input.file_path()
|> File.read!()
analyze_document
で Textract に画像解析を依頼します
Bytes
に画像バイナリーの BASE64 エンコードした文字列を渡しています
(S3 にアップロードしておいて S3 バケット、 S3 キーを指定することも可能です)
FeatureTypes
に TABLES
を指定することで、画像内のテーブルを解析します
(他にも FORMS
や LAYOUT
が指定可能です)
blocks =
client
|> AWS.Textract.analyze_document(%{
"Document" => %{
"Bytes" => Base.encode64(image_binary)
},
"FeatureTypes" => ["TABLES"]
})
|> elem(1)
|> Map.get("Blocks")
実行結果
[
%{
"BlockType" => "PAGE",
"Geometry" => %{
"BoundingBox" => %{"Height" => 1.0, "Left" => 0.0, "Top" => 0.0, "Width" => 1.0},
"Polygon" => [
%{"X" => 2.5151466616080143e-6, "Y" => 0.0},
%{"X" => 1.0, "Y" => 3.447757990215905e-6},
%{"X" => 1.0, "Y" => 1.0},
%{"X" => 0.0, "Y" => 0.999849259853363}
]
},
...
]
実行結果はブロック単位で返ってきます
BlockType 毎に色々な情報が入っています
BlockType が TABLE のデータを抜き出してみましょう
Enum.filter(blocks, fn block -> block["BlockType"] == "TABLE" end)
実行結果
[
%{
"BlockType" => "TABLE",
"Confidence" => 98.828125,
"EntityTypes" => ["STRUCTURED_TABLE"],
"Geometry" => %{
"BoundingBox" => %{
"Height" => 0.6956124305725098,
"Left" => 0.013993375934660435,
"Top" => 0.25411152839660645,
"Width" => 0.9783072471618652
},
"Polygon" => [
%{"X" => 0.016160983592271805, "Y" => 0.25411152839660645},
%{"X" => 0.9923005700111389, "Y" => 0.2549054026603699},
%{"X" => 0.9899793863296509, "Y" => 0.9497239589691162},
%{"X" => 0.013993375934660435, "Y" => 0.9489028453826904}
]
},
"Id" => "4a361ea3-d236-4f74-ac02-f767c955fb5d",
"Relationships" => [
%{
"Ids" => ["eacc1cc6-63bf-4952-935d-8871f91dfe2a", "9e6e0787-b58a-4cdc-9050-4e53c0cac941",
"7ea1fed9-68e5-45ed-8fed-d29831bdeafe", "4adc57b8-c6e7-4301-9cba-3a02beab5d55",
"434b202f-5d21-4cc0-9d4b-339946dea5af", "b7322da7-64e9-490d-8a60-69724ada3ee2",
"8b65bc69-0257-4b5c-87b3-0216654a3c3a", "17ed2ec9-07e8-4f2f-b3c2-53a737af710a",
"6aa1c9aa-ff89-48c1-973d-14ea8cfe9638", "5f526642-4f2c-4457-96c1-7afa62420082",
"bec509a4-bf59-4b1a-bd52-d733e62bf3d8", "62976b04-a26d-447e-afab-acea9189828e",
"187d1f63-8837-4073-a5ec-da8e1e44e95f", "cb54b3c1-3e83-406b-bbb8-2944532b4700",
"4190cf0c-c097-4b90-93de-9cac067b071f", "d5ca7e68-0e81-4ba7-8ac2-4243c694d8b7",
"ce1e39e2-7477-43f0-ae13-d4fe17a37ba6", "329f3eae-c6cb-4226-93b8-129745f76bd5",
"ab2dcee9-bf0b-48ec-801e-9518f2610dc9", "315dd087-004a-4ebe-aa66-0c0778e9fe47"],
"Type" => "CHILD"
},
%{"Ids" => ["4cae0bb2-858e-49ea-a52f-5e06809e26e0"], "Type" => "MERGED_CELL"}
]
}
]
表全体の位置情報(四隅の座標)と、表の中に入っている子要素の情報が参照できます
続いて BlockType が CELL のデータを抜き出してみます
Enum.filter(blocks, fn block -> block["BlockType"] == "CELL" end)
実行結果
[
%{
"BlockType" => "CELL",
"ColumnIndex" => 1,
"ColumnSpan" => 1,
"Confidence" => 61.1328125,
"Geometry" => %{
"BoundingBox" => %{
"Height" => 0.1740656942129135,
"Left" => 0.015354328788816929,
"Top" => 0.25238272547721863,
"Width" => 0.03279806673526764
},
"Polygon" => [
%{"X" => 0.01589728519320488, "Y" => 0.25238272547721863},
%{"X" => 0.04815239459276199, "Y" => 0.2524089813232422},
%{"X" => 0.04760816693305969, "Y" => 0.42644843459129333},
%{"X" => 0.015354328788816929, "Y" => 0.42642197012901306}
]
},
"Id" => "eacc1cc6-63bf-4952-935d-8871f91dfe2a",
"RowIndex" => 1,
"RowSpan" => 1
},
%{
"BlockType" => "CELL",
"ColumnIndex" => 2,
"ColumnSpan" => 1,
"Confidence" => 61.1328125,
"Geometry" => %{
"BoundingBox" => %{
"Height" => 0.1741279512643814,
"Left" => 0.04760816693305969,
"Top" => 0.2524089813232422,
"Width" => 0.10843262076377869
},
"Top" => 0.2524089813232422,
"Width" => 0.10843262076377869
},
"Polygon" => [
%{"X" => 0.04815239459276199, "Y" => 0.2524089813232422},
%{"X" => 0.15604078769683838, "Y" => 0.2524966895580292},
%{"X" => 0.15549230575561523, "Y" => 0.4265369176864624},
%{"X" => 0.04760816693305969, "Y" => 0.42644843459129333}
]
},
"Id" => "9e6e0787-b58a-4cdc-9050-4e53c0cac941",
%{"X" => 0.04760816693305969, "Y" => 0.42644843459129333}
]
},
"Id" => "9e6e0787-b58a-4cdc-9050-4e53c0cac941",
"Relationships" => [
%{
"Ids" => ["44566f03-e774-4e27-9028-b2fa9ef7f0c6", "2b7bcb56-75ab-45d7-bc70-c60e86034a35"],
"Type" => "CHILD"
}
],
"RowIndex" => 1,
"RowSpan" => 1
},
...
]
テーブル内のセル毎に RowIndex
= 行番号や ColumnIndex
= 列番号を持っています
セルの内側に子要素がある場合、 Relationships
で子要素の Id を示しています
最後に BlockType が WORD のデータ
Enum.filter(blocks, fn block -> block["BlockType"] == "WORD" end)
実行結果
[
%{
"BlockType" => "WORD",
"Confidence" => 99.84880065917969,
"Geometry" => %{
"BoundingBox" => %{
"Height" => 0.050695087760686874,
"Left" => 0.015485275536775589,
"Top" => 0.08100071549415588,
"Width" => 0.038695987313985825
},
"Polygon" => [
%{"X" => 0.015643324702978134, "Y" => 0.08100071549415588},
%{"X" => 0.054181262850761414, "Y" => 0.08103178441524506},
%{"X" => 0.05402277037501335, "Y" => 0.13169580698013306},
%{"X" => 0.015485275536775589, "Y" => 0.13166464865207672}
]
},
"Id" => "bb8730e4-d7c0-49a6-a539-1d2e26490072",
"Text" => "Data",
"TextType" => "PRINTED"
},
...
]
こちらはセル内の単語毎になっていて、 Text
に認識した文字列が返ってきています
構造的には以下のようになっていますね
<block BlockType="TABLE">
<block BlockType="CELL" RowIndex=1 ColumnIndex=1>
<block BlockType="WORD" Text="AAA">
</block>
<block BlockType="WORD" Text="BBB">
</block>
</block>
<block BlockType="CELL" RowIndex=1 ColumnIndex=2>
<block BlockType="WORD" Text="CCC">
</block>
</block>
<block BlockType="CELL" RowIndex=2 ColumnIndex=1>
<block BlockType="WORD" Text="DDD">
</block>
</block>
<block BlockType="CELL" RowIndex=2 ColumnIndex=2>
<block BlockType="WORD" Text="EEE">
</block>
</block>
</block>
このままでは扱いづらいので、構造を変換しましょう
cells =
blocks
|> Enum.filter(fn block -> block["BlockType"] == "CELL" end)
|> Enum.map(fn cell ->
# 各セルについて、子要素の Text を取得する
words =
case cell["Relationships"] do
nil ->
""
relationships ->
relationships
|> Enum.filter(fn child -> child["Type"] == "CHILD" end)
|> Enum.map(fn child ->
Enum.map(child["Ids"], fn child_id ->
blocks
|> Enum.find(%{}, fn block -> block["Id"] == child_id end)
|> Map.get("Text", "")
end)
end)
|> Enum.join(" ")
end
cell
|> Map.take(["ColumnIndex", "RowIndex"])
|> Map.put("Text", words)
end)
Kino.DataTable.new(cells)
セル毎に列番号、行番号、テキストを持つ Map に変換できました
さらに行毎のデータに変換します
行番号が 1 のデータは列名です
元画像で文字列型を示す「A」の文字が付いていた為に、文字認識結果にも余計に「A」が付いているため、先頭一文字を取り除きます(String.slice(<文字列>, 1..-1))
)
columns =
cells
|> Enum.filter(fn cell -> cell["RowIndex"] == 1 end)
|> Enum.map(fn column ->
Map.put(column, "Text", String.slice(column["Text"], 1..-1))
end)
実行結果
[
%{"ColumnIndex" => 1, "RowIndex" => 1, "Text" => ""},
%{"ColumnIndex" => 2, "RowIndex" => 1, "Text" => "Gender"},
%{"ColumnIndex" => 3, "RowIndex" => 1, "Text" => "Id"},
%{"ColumnIndex" => 4, "RowIndex" => 1, "Text" => "LanguageName"},
%{"ColumnIndex" => 5, "RowIndex" => 1, "Text" => "Name"}
]
行毎にループしたいので、最大の行番号を取得します
max_row_index =
cells
|> Enum.map(fn cell -> cell["RowIndex"] end)
|> Enum.max()
実行結果
4
各行毎にデータを取り出します
rows =
2..max_row_index
|> Enum.map(fn row_index ->
Enum.filter(cells, fn cell -> cell["RowIndex"] == row_index end)
end)
実行結果
[
[
%{"ColumnIndex" => 1, "RowIndex" => 2, "Text" => "1"},
%{"ColumnIndex" => 2, "RowIndex" => 2, "Text" => "Female"},
%{"ColumnIndex" => 3, "RowIndex" => 2, "Text" => "Kazuha"},
%{"ColumnIndex" => 4, "RowIndex" => 2, "Text" => "Japanese"},
%{"ColumnIndex" => 5, "RowIndex" => 2, "Text" => "Kazuha"}
],
[
%{"ColumnIndex" => 1, "RowIndex" => 3, "Text" => "2"},
%{"ColumnIndex" => 2, "RowIndex" => 3, "Text" => "Female"},
%{"ColumnIndex" => 3, "RowIndex" => 3, "Text" => "Tomoko"},
%{"ColumnIndex" => 4, "RowIndex" => 3, "Text" => "Japanese"},
%{"ColumnIndex" => 5, "RowIndex" => 3, "Text" => "Tomoko"}
],
[
%{"ColumnIndex" => 1, "RowIndex" => 4, "Text" => "3"},
%{"ColumnIndex" => 2, "RowIndex" => 4, "Text" => "Male"},
%{"ColumnIndex" => 3, "RowIndex" => 4, "Text" => "Takumi"},
%{"ColumnIndex" => 4, "RowIndex" => 4, "Text" => "Japanese"},
%{"ColumnIndex" => 5, "RowIndex" => 4, "Text" => "Takumi"}
]
]
列名 = 値となるよう、 Map に変換します
table_data =
rows
|> Enum.map(fn row ->
columns
|> Enum.into(%{}, fn column ->
value =
row
|> Enum.find(fn cell -> cell["ColumnIndex"] == column["ColumnIndex"] end)
|> Map.get("Text")
{column["Text"], value}
end)
end)
Kino.DataTable.new(table_data)
元画像のテーブルを再現できました
画像へのクエリ
表データの取得自体は簡単でしたが、それを扱いやすい形に変換するのは結構大変でした
実際にはセル結合等あるため、単純にいかないケースも多そうです
そういうとき、もっと楽にデータを扱う方法も存在します
それがクエリです
画像に対して自然言語で(日常的な文章で)質問を投げると、なんと結果を返してくれます
FeatureTypes
に ["QUERIES"]
を指定し、 QueriesConfig.Queries[].Text
に質問文を指定します
今回は What is the Takumi's gender
と指定し、 Takumi の性別を聞いてみます
blocks =
client
|> AWS.Textract.analyze_document(%{
"Document" => %{
"Bytes" => Base.encode64(image_binary)
},
"FeatureTypes" => ["QUERIES"],
"QueriesConfig" => %{
"Queries" => [
%{
"Text" => "What is the Takumi's gender"
}
]
}
})
|> elem(1)
|> Map.get("Blocks")
実行結果はテーブルの場合と同様ブロックで返ってきます
その中から BlockType が QUERY_RESULT のブロックを取得します
Enum.filter(blocks, fn block -> block["BlockType"] == "QUERY_RESULT" end)
実行結果
[
%{
"BlockType" => "QUERY_RESULT",
"Confidence" => 93.0,
"Geometry" => %{
"BoundingBox" => %{
"Height" => 0.04827306047081947,
"Left" => 0.06017465889453888,
"Top" => 0.834955096244812,
"Width" => 0.037357721477746964
},
"Polygon" => [
%{"X" => 0.06032566353678703, "Y" => 0.834955096244812},
%{"X" => 0.09753238409757614, "Y" => 0.8349862694740295},
%{"X" => 0.09738096594810486, "Y" => 0.8832281827926636},
%{"X" => 0.06017465889453888, "Y" => 0.8831969499588013}
]
},
"Id" => "5e81eb91-0216-422b-9976-7f10d9fbc850",
"Text" => "Male"
}
]
ブロックの中の Text
に Male
= 男性、と返ってきました
これならデータの構造などを考える必要もなく、画像にある情報を抜き出せます
まとめ
Livebook から Amazon Textract を使った文字認識が実装できました
表データを取得できるだけでなく、クエリを使うことでそのまま質問に答えてもくれます
古い紙データを電子化するときに使えそうですね