14
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ElixirAdvent Calendar 2023

Day 18

Amazon Textract による「画像からの表データ読込」を Livebook から実行する

Last updated at Posted at 2023-12-12

はじめに

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)などを指定してください

スクリーンショット 2023-12-03 23.53.29.png

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 のときに撮ったスクリーンショット)を対象として、画像内の表データを読み取ってみます

スクリーンショット 2023-12-09 23.03.44.png

まず、画像入力を用意します

image_input = Kino.Input.image("IMAGE", format: :png)

表示されたフォームに画像をドラッグ&ドロップ、もしくは Upload ボタンから画像を選択すると、画像が表示されます

スクリーンショット 2023-12-10 14.11.53.png

画像を Textract に送るため、バイナリデータとして読み込みます

image_binary =
  image_input
  |> Kino.Input.read()
  |> Map.get(:file_ref)
  |> Kino.Input.file_path()
  |> File.read!()

スクリーンショット 2023-12-10 14.15.29.png

analyze_document で Textract に画像解析を依頼します

Bytes に画像バイナリーの BASE64 エンコードした文字列を渡しています
(S3 にアップロードしておいて S3 バケット、 S3 キーを指定することも可能です)

FeatureTypesTABLES を指定することで、画像内のテーブルを解析します
(他にも FORMSLAYOUT が指定可能です)

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)

実行結果
スクリーンショット 2023-12-10 14.38.18.png

セル毎に列番号、行番号、テキストを持つ 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)

実行結果
スクリーンショット 2023-12-10 14.45.51.png

元画像のテーブルを再現できました

画像へのクエリ

表データの取得自体は簡単でしたが、それを扱いやすい形に変換するのは結構大変でした

実際にはセル結合等あるため、単純にいかないケースも多そうです

そういうとき、もっと楽にデータを扱う方法も存在します

それがクエリです

画像に対して自然言語で(日常的な文章で)質問を投げると、なんと結果を返してくれます

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"
  }
]

ブロックの中の TextMale = 男性、と返ってきました

これならデータの構造などを考える必要もなく、画像にある情報を抜き出せます

まとめ

Livebook から Amazon Textract を使った文字認識が実装できました

表データを取得できるだけでなく、クエリを使うことでそのまま質問に答えてもくれます

古い紙データを電子化するときに使えそうですね

14
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?