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

## はじめに

Amazon Textract は AWS 上で使える AI による画像文字認識、いわゆる OCR サービスです

ただし、 Textract は単に文字を認識するだけでなく、表や入力フォームなどの構造を認識し、意味のある塊でデータを読み込むことができます

ブラウザからお手軽に試す場合、以下の記事を参考にしてください

https://qiita.com/RyoWakabayashi/items/b7bd7079d20326d9e4be

:::note warn
日本語には未対応であるため、英数字のデータしか認識できません
:::

本記事では Elixir の Livebook から Textract を使い、画像からの表データ読込を実装します

実装したノートブックはこちら

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/aws/textract.livemd

## Livebook とは

Livebook は Elixir　のコードをブラウザから実行し、結果を表示してくれるツールです

https://livebook.dev/

Python における Jupyter のようなもので、 Elixir 入門や Elixir を使ったデータ分析、データの視覚化などに適しています

はじめ方は以下の記事を参考にしてください

https://qiita.com/RyoWakabayashi/items/dc82ad3ed866d58a8acf

## セットアップ

Livebook で新しいノートブックを開いたら、先頭のセットアップセルに以下のコードを入力し、実行してください

```elixir
Mix.install([
  {:aws, "~> 0.13"},
  {:hackney, "~> 1.20"},
  {:kino, "~> 0.11"}
])
```

AWS サービスの操作用に AWS Elixir というモジュールをインストールしています

https://github.com/aws-beam/aws-elixir/tree/master

## クライアントの準備

AWS の認証情報を入力するためのテキストボックスを準備します

```elixir
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/ddaeb2f0-6469-ead9-5815-2ec34dfbe88b.png)

AWS の API と通信するためのクライアントを用意します
この際、先ほどの認証情報を受け渡します

```elixir
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/2df25c82-4735-09f1-c552-82e536458352.png)

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

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

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

![スクリーンショット 2023-12-10 14.11.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/36553648-40e1-2991-d776-4a44bf0bf106.png)

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

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

![スクリーンショット 2023-12-10 14.15.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/92435c93-72f0-7ea8-eec4-593c71ca0c82.png)

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

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

`FeatureTypes` に `TABLES` を指定することで、画像内のテーブルを解析します
（他にも `FORMS` や `LAYOUT` が指定可能です）

```elixir
blocks =
  client
  |> AWS.Textract.analyze_document(%{
    "Document" => %{
      "Bytes" => Base.encode64(image_binary)
    },
    "FeatureTypes" => ["TABLES"]
  })
  |> elem(1)
  |> Map.get("Blocks")
```

実行結果
```elixir
[
  %{
    "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 のデータを抜き出してみましょう

```elixir
Enum.filter(blocks, fn block -> block["BlockType"] == "TABLE" end)
```

実行結果
```elixir
[
  %{
    "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 のデータを抜き出してみます

```elixir
Enum.filter(blocks, fn block -> block["BlockType"] == "CELL" end)
```

実行結果
```elixir
[
  %{
    "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 のデータ

```elixir
Enum.filter(blocks, fn block -> block["BlockType"] == "WORD" end)
```

実行結果
```elixir
[
  %{
    "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` に認識した文字列が返ってきています

構造的には以下のようになっていますね

```xml
<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>
```

このままでは扱いづらいので、構造を変換しましょう

```elixir
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/e3695871-ee08-ed2e-513c-a7eee20b5d8c.png)

セル毎に列番号、行番号、テキストを持つ Map に変換できました

さらに行毎のデータに変換します

行番号が 1 のデータは列名です

元画像で文字列型を示す「A」の文字が付いていた為に、文字認識結果にも余計に「A」が付いているため、先頭一文字を取り除きます（`String.slice(<文字列>, 1..-1))`）

```elixir
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)
```

実行結果
```elixir
[
  %{"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"}
]
```

行毎にループしたいので、最大の行番号を取得します

```elixir
max_row_index =
  cells
  |> Enum.map(fn cell -> cell["RowIndex"] end)
  |> Enum.max()
```

実行結果
```elixir
4
```

各行毎にデータを取り出します
```elixir
rows =
  2..max_row_index
  |> Enum.map(fn row_index ->
    Enum.filter(cells, fn cell -> cell["RowIndex"] == row_index end)
  end)
```

実行結果
```elixir
[
  [
    %{"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 に変換します

```elixir
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/d15e8b2c-6d79-244a-cbef-3fe70df0e627.png)

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

## 画像へのクエリ

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

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

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

それがクエリです

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

`FeatureTypes` に `["QUERIES"]` を指定し、 `QueriesConfig.Queries[].Text` に質問文を指定します

今回は `What is the Takumi's gender` と指定し、 Takumi の性別を聞いてみます

```elixir
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 のブロックを取得します

```elixir
Enum.filter(blocks, fn block -> block["BlockType"] == "QUERY_RESULT" end)
```

実行結果
```elixir
[
  %{
    "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 を使った文字認識が実装できました

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

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