【IBM watsonx.ai】Text Extractionによる画像上のテキスト抽出と位置情報の可視化

# Text Extractionとは
watsonx.aiには画像ファイルからテキストを抽出するAI-OCRサービスがあります。
この記事では、watsonx.ai Text ExtractionのPythonライブラリを使用して、画像上のテキストの読み取り、位置検出を行う手順を紹介します。
さらに、テキスト検出結果を可視化する方法として、画像上にバウンディングボックスを描画する方法も紹介します。

# テキスト抽出のフロー
1. IBM Cloud Object Storageに画像をアップロードする
1. Text Extractionへのリクエストを作成する
1. Text Extractionのステータスを見て、テキスト抽出に成功したことを確認する
1. テキスト抽出結果をIBM Cloud Object Storageから取得する
![wxai-text-extraction-api.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957734/b1d9b257-2204-08a8-500a-db3456bddb14.png)
:::note warn
注意
この画像はREST APIを使用した際のフローですが、Pythonライブラリを使用する場合もほとんど同様の処理となります。
:::

# IBM Cloud Object Storageの設定
### IBM Cloud Object Storageのバケット作成
1. IBM Cloudのリソース・リストから、ストレージを展開し、IBM Cloud Object Storageサービス・インスタンスをクリックして開く
1. バケットの作成をクリックする
    ![スクリーンショット 2024-12-10 17.28.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957734/fb3e3003-739b-ac51-3f58-f22cf31bc3d9.png)
1. **すぐに始める**方法でバケットを作成する
    ![スクリーンショット 2024-12-10 17.28.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957734/578b0a15-bc07-0360-b6cc-0e6d47e1cd87.png)
1. 固有のバケット名をつけて、バケットを資格情報とともに作成する
    ![makebacket.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957734/ba7926fe-5ff8-895e-617b-a0204b51c1a4.png)
1. ファイルのアップロード、バケットのテストはスキップし、バケットの作成を完了する
    ![スクリーンショット 2024-12-12 10.25.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957734/759daa0a-9919-da0d-4712-a39f91a0de81.png)

### IBM Cloud Object Storageサービス・インスタンスへの接続情報の取得
watsonx.aiからIBM Cloud Object Storageのバケットに接続するため、HMACクレデンシャルをもつ鍵を作成する。
1. IBM Cloud Object Storageの対象のサービス・インスタンスのページで、サービス資格情報を開き、新規資格情報をクリック
    ![スクリーンショット 2024-12-12 10.57.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957734/673b5040-1cc8-1724-5d57-c42729e21a39.png)

1. 適当な名前で資格情報を作成する
    - ロール：管理者
    - HMAC資格情報を含める：オン
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957734/32a8a126-ad0f-7b2a-50f1-4d485e0e9f00.png)
1. `access_key_id`と`secret_access_key`の値を辞書から取得する
    ![スクリーンショット 2024-12-12 11.55.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957734/72672aa2-a92d-a036-b5fb-843c9b1a97e4.png)

対象のバケットの**構成**を開き、エンドポイントから接続先のパブリックURLを取得する。
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957734/2eb2a818-c884-e896-e33a-c80ebbb68ea7.png)


# watsonx.aiの設定
### プロジェクトの作成
1. watsonxのページを開く
1. プロジェクトの右側の+ボタンをクリックし、プロジェクトの新規作成ページを開く
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957734/2c83f81d-c575-472f-9a46-907c7637af39.png)
1. 適当な名前をつけて、プロジェクトを作成する
    ![スクリーンショット 2025-02-17 17.20.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957734/b488871c-23aa-4b9b-8107-38dfdff90d58.png)
1. プロジェクト → 管理 → 一般をクリックし、プロジェクトIDを取得する
※プロジェクト作成時にもバケットが作成されますが、今回はこのバケットは使用しません。
    ![スクリーンショット 2025-02-17 17.27.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957734/3e0d6a94-cce9-4bd9-aff3-e2e04f4e841c.png)

### プロジェクトとIBM Cloud Object Storageの接続
1. 作成したプロジェクトを開く
1. アセットページを開き、新規資産をクリック
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957734/16bb4c27-42cb-4dfc-8d80-0aa78b5e81ce.png)
1. データ・ソースへの接続をクリック
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957734/fd1df270-5b50-21b1-5879-3dc4f74d7eeb.png)
1. IBM Cloud Object Storageを選択し、接続の追加を行う
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957734/5cf31fa5-c839-41fb-80df-75b40d5e54c4.png)
1. 接続の設定を行い、テストを確認した上でアセットを作成する
    - 名前：適当
    - バケット：上で作成したバケット名
    - ログインURL：上で取得したURL
    - 認証方法：アクセス・キーと秘密鍵
        - アクセス・キー：`access_key_id`の値
        - 秘密鍵：`secret_access_key`の値
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957734/df720e6f-1cb4-8093-9130-24190d277083.png)
1. 作成した接続を開き、そのページのURLから`connection_asset_id`を取得する
(例．URLが"eu-gb.dataplatform.cloud.ibm.com/connections/&lt;connection_asset_id&gt;?project_id=&lt;project_id&gt;&context=wx"の場合、&lt;connection_asset_id&gt;の部分を取得する)
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957734/e963fc61-5152-420d-8498-d815de01cf8d.png)



# Python上でText Extractionサービスを使用する
### 環境のセットアップ
#### 必要なパッケージをインストールする。
```bash:bash
pip install "ibm-watsonx-ai>=1.1.15"
```

#### watsonx.aiに接続するためのクレデンシャルを設定する
watsonx.aiのAPIキーを作成する手順は以下のソースが参考になると思います。
- 参考1：[ユーザーの API キーの管理](https://cloud.ibm.com/docs/account?topic=account-userapikey&interface=ui)
- 参考2：[watsonxのAPIを呼び出せるようになるまで](https://qiita.com/katahiro/items/3258cd42226ed82268ac)

※クレデンシャルに設定するURLはリージョンによって変わります。
```python:python
from ibm_watsonx_ai import Credentials

credentials = Credentials(
    url="https://eu-gb.ml.cloud.ibm.com",
    api_key="<watsonx.ai_api_key>"
)
```
#### API Clientを初期化する
`project_id`には、watsonx.aiの設定時に取得したものをセットする。
```python:python
from ibm_watsonx_ai import APIClient

project_id = "<project_id>"
client = APIClient(credentials=credentials, project_id=project_id)
```

### IBM Cloud Object Storageへテキスト抽出対象ファイルをアップロードする
今回はテキスト抽出対象ファイルとして、この記事の冒頭にも載せたテキスト抽出フローの画像を使用する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957734/fa4445b5-5aa0-40ed-90d1-d06a61ddb3d1.png)

`bucketname`には前の手順で設定した名前を定義する。（私の場合："cloud-object-storage-for-text-extraction"）
`connection_asset_id`には接続の作成時に取得した値を定義する。

```python:python
from ibm_watsonx_ai.helpers import DataConnection, S3Location

# ローカル上のテキスト抽出対象ファイルの名前
local_source_file_name = "text_extraction_flow.png"
# COS上のテキスト抽出対象ファイルの名前
source_file_name = "./files/text_extraction_flow.png"
# COS上のテキスト抽出結果ファイルの名前（マークダウン形式で出力する場合は.mdにする）
results_file_name = "./files/text_extraction_result.json"

bucketname = "<bucketname>"
connection_asset_id = "<connection_asset_id>"

# ローカルファイルをCOSにアップロードするための接続を設定
remote_document_reference = DataConnection(
    connection_asset_id=connection_asset_id,
    location=S3Location(bucket=bucketname, path="."),
)
# COSクライアントを設定（.write時に直接バケットを触るため必要）
remote_document_reference.set_client(client)
# COS上にテキスト抽出対象ファイルをアップロード
remote_document_reference.write(local_source_file_name, remote_name=source_file_name)
```

### 画像からテキスト抽出を実行し、抽出結果をローカルにダウンロードする
テキスト抽出ジョブ（`extraction.run_job`）は非同期処理なので、完了を待ってからダウンロードする必要があります。
また、今回は抽出テキストの言語として英語、画像内の表はなしとして設定しました。
より詳細な設定オプションについては、[Text Extractionsライブラリのドキュメント](https://ibm.github.io/watsonx-ai-python-sdk/fm_text_extraction.html)を参照ください。
```python:python
import time

# アップロードしたファイルを処理対象として参照するための接続を設定
document_reference = DataConnection(
    connection_asset_id=connection_asset_id,
    location=S3Location(bucket=bucketname, path=source_file_name),
)

# COS上のテキスト抽出結果ファイルの名前（マークダウン形式で出力する場合は.mdにする）
results_file_name = "./files/text_extraction_result.json"
# 処理結果を保存するための接続を設定
results_reference = DataConnection(
    connection_asset_id=connection_asset_id,
    location=S3Location(bucket=bucketname, path=results_file_name),
)

# Text Extractionのインスタンスを作成
extraction = TextExtractions(credentials=credentials, project_id=project_id)

# テキスト抽出を実行
response = extraction.run_job(
    document_reference=document_reference,
    results_reference=results_reference,
    steps={
        TextExtractionsMetaNames.OCR: {"languages_list": ["en"]}, # 対象言語
        TextExtractionsMetaNames.TABLE_PROCESSING: {"enabled": False}, # 画像内に表が存在するかどうか
    },
    results_format="json" # 出力形式
)
# EXTRACTION_IDの取得
extraction_job_id = extraction.get_id(extraction_details=response)

# ジョブの完了を待つ（非同期処理）
while True:
    job_details = extraction.get_job_details(extraction_id=extraction_job_id)
    status = job_details['entity']['results']['status']
    if status == 'completed':
        break
    time.sleep(10)  # 10秒ごとにステータスチェック

# 結果のダウンロード
results_reference = extraction.get_results_reference(extraction_id=extraction_job_id)
output_filename = "text_extraction_result.json"
results_reference.download(filename=output_filename)
```
:::note info 
今回は画像内のテキスト位置を可視化するため、位置情報を含むJSON形式で出力しました。
一般的な文書からのテキスト抽出では、文書構造を保持できるMarkdown形式で出力した方が良いかと思います。
:::

# 抽出されたJSONファイルの構造
Text Extractionで画像から抽出したJSONファイルの構造を説明します。
### 基本構造
```json:json
{
  "metadata": { ... },                   // ドキュメントの基本情報
  "styles": [ ... ],                     // テキストのスタイル定義
  "top_level_structures": [ ... ],       // ルートレベルの要素ID
  "all_structures": {                    // 文書の構造情報
    "sections": [ ... ],                 // セクション情報
    "paragraphs": [ ... ],               // 段落情報
    "tokens": [ ... ]                    // 個々のテキスト要素
  }
}
```
抽出されたテキストは階層構造で管理されており、`sections` → `paragraphs` → `tokens`という流れで文書の構造を表現しています。

### tokensの詳細
`tokens`は実際に抽出されたテキストとその位置情報を含む最も重要な部分です。
画像のタイトル部分を例に、トークンの分割を説明します：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957734/a7a7507f-0690-4d14-912a-985f3768a34d.png)

上記のタイトル「IBM watsonx.ai text extraction API workflow」は、以下のように個別のトークンに分割されています：
- TOKEN_362e51: "IBM"
- TOKEN_365932: "watsonx.ai"
- TOKEN_514535: "text"
- TOKEN_d791aa: "extraction"
- TOKEN_fe6e6e: "API"
- TOKEN_632471: "workflow"

各トークンには以下のような詳細情報が含まれています：
```json:json
{
  "id": "TOKEN_362e51",
  "text": "IBM",          // 抽出されたテキスト
  "bbox": {               // テキストの位置情報
    "page_number": 1,     // ページ番号
    "x": 119.25,          // 左からの位置
    "y": 36.37,           // 上からの位置
    "width": 27.36,       // テキストの幅
    "height": 12.0        // テキストの高さ
  }
}
```
この`bbox`（バウンディングボックス）情報により、元の画像内でのテキストの正確な位置がわかります。

# テキスト抽出結果の可視化：バウンディングボックスの描画
Text Extractionによるテキスト抽出結果を可視化するPythonスクリプトです。抽出されたテキストの位置を元の画像上に赤いバウンディングボックスで表示します。

```python:python
from PIL import Image, ImageDraw
import json

def draw_bounding_boxes(image_path, json_data):
    # 画像を開く
    img = Image.open(image_path)
    
    # JSONデータの座標範囲を確認
    tokens = json_data['all_structures']['tokens']
    
    # スケーリング係数を計算（ポイントからピクセルへ）
    scale_x = 1.333
    scale_y = 1.333
    
    draw = ImageDraw.Draw(img)
    
    # 各トークンに対して赤い枠を描画
    for token in tokens:
        bbox = token['bbox']
        
        # ポイントからピクセルに変換
        x = bbox['x'] * scale_x
        y = bbox['y'] * scale_y
        width = bbox['width'] * scale_x
        height = bbox['height'] * scale_y
        
        # 赤い枠を描画
        draw.rectangle(
            [(x, y), (x + width, y + height)],
            outline='red',
            width=2
        )
    
    # 結果を保存
    output_path = './text_extraction_flow_with_boxes.png'
    img.save(output_path)
    print(f"画像を保存しました: {output_path}")

# JSONデータを解析
with open("./text_extraction_result.json", "r") as f:
    json_data = json.load(f)

# 元の図面の画像パスを指定して実行
draw_bounding_boxes('./text_extraction_flow.png', json_data)
```
### 重要なポイント
- スケーリング係数1.333を使用してText Extractionから返されるポイント座標をピクセル座標に変換
- `tokens`配列の各要素に含まれる`bbox`情報から位置とサイズを取得
- バウンディングボックスは赤色、線の太さ2ピクセルで描画
:::note warn
注意
スケーリング係数（1.333）は画像のDPIや使用しているOSによって調整が必要な場合があります。
参考：[px(pixel)とpt(point)について](https://zenn.dev/kisukeyas/articles/31c15fab84b9dd)
:::

### 処理後の画像（バウンディングボックス付き）
![text_extraction_flow_with_boxes.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957734/c9ca1e7d-9b5b-4da2-82bd-61e8fe463b34.png)

今回のスクリプトでは`tokens`ごとにバウンディングボックスをつけましたが、工夫次第で`paragraph`や`section`単位で処理することも可能かと思います。

# おわりに
今回はIBM watsonx.aiのText ExtractionというAI-OCRサービスの使用手順を紹介しました。VLMの進化が著しい中でも、画像や文書上のテキストとその位置情報を高精度で抽出するには、現状AI-OCRの特徴を活かせる場面が多いと考えられます。

# 参考
- Text Extraction
    - [文書からのテキスト抽出](https://dataplatform.cloud.ibm.com/docs/content/wsj/analyze-data/fm-api-text-extraction.html?context=wx&locale=ja)
    - [プログラムでファイルからテキストを抽出する](https://dataplatform.cloud.ibm.com/docs/content/wsj/analyze-data/fm-text-extraction-notebook.html?context=wx&locale=ja)
    - [Text Extractionsライブラリのドキュメント](https://ibm.github.io/watsonx-ai-python-sdk/fm_text_extraction.html)
    - [Use watsonx.ai Text Extraction service to extract text from file](https://github.com/IBM/watsonx-ai-samples/blob/master/cloud/notebooks/python_sdk/deployments/foundation_models/Use%20watsonx%20Text%20Extraction%20service%20to%20extract%20text%20from%20file.ipynb)
- watsonx.aiのAPI
    - [watsonxのAPIを呼び出せるようになるまで](https://qiita.com/katahiro/items/3258cd42226ed82268ac)
    - [ユーザーの API キーの管理](https://cloud.ibm.com/docs/account?topic=account-userapikey&interface=ui)
- その他
    - [px(pixel)とpt(point)について](https://zenn.dev/kisukeyas/articles/31c15fab84b9dd)