Python+Cloud Vision APIでOCR、Bounding Boxまで！

# はじめに
自分のプロジェクトで、特に自然言語処理をやっている方々は、データマイニング作業とかやるときに、画像内の文章を抽出したいと言う作業がいずれか出てくると思います。そう言う時は、「GCPのCloud Vision APIを使えばいいのでは？」ってなると思うんですが、やったことある人にはわかるかもしれないんですけど、帰ってきた結果は文章になっていなかったり、バウンディングボックスの座標も各単語の座標になっていて、一個の文章に対するバウンディングボックスになっていないのが困りますよねー

今回は、これらの作業をチュートリアル形式でやっていきたいと思いますので、是非皆さんのデータマイニング作業に役に立ててればなと思います！ただ、このチュートリアルの前提としては、自分のGCPのアカウントを持っていることを想定しているので、やってみたいけど、アカウント持っていない方は是非アカウント発行してください！[無料](https://console.cloud.google.com/freetrial?hl=ja&_ga=2.100299998.-1715293248.1575339025)です！（利用可能なクレジットは限られているけど）

アカウント作成し、プロジェクトを作成後には、一度自分のプロジェクトの課金設定が有効になっているかどうかを確認してください。Cloud Vision APIは**無料ではない**が、GCPのアカウントを最初に作るときに、無料クレジットが割り当てられているので、APIの試しだけであれば、そのクレジットでも十分に足りていると思っています。自分のプロジェクトの課金設定が有効になっているかどうかをチェックするには、この[リンク](https://cloud.google.com/billing/docs/how-to/modify-project?hl=ja)にしたがってやってください。

# Cloud Vision APIをアクティベート！
### プロジェクト作成
まず、GCPでプロジェクトを作りましょう。[コンソール画面](https://console.cloud.google.com/home/)をアクセスすると、下の画面が出てきます。ホーム画面の左上にある「プロジェクト名」が書いてあるボタンをクリックします。

<img width="457" alt="Screen Shot 2019-12-17 at 19.10.57.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539492/2dd0b3eb-b964-fa27-3c2b-034600f93e9a.png">

そしたら、このような画面が出てきて、「新しいプロジェクト」をクリック！

<img width="755" alt="Screen Shot 2019-12-17 at 19.13.26.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539492/488b9fab-5088-6ba6-9b5f-b2cda214b0b1.png">

画面にしたがってプロジェクト名と場所（あれば）を指定し、作成ボタンをクリックし、完了です！

<img width="550" alt="Screen Shot 2019-12-17 at 19.19.56.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539492/4003c91f-58cb-5041-e640-e1af2dfc3d32.png">

### Cloud Vision APIを使えるようにする

Cloud Vision APIを使えるようにするには、API自体を有効にしなければなりません。以下の手順にしたがって有効にしましょう！

コンソール画面に戻ったら、作成したプロジェクトを選択しているようになっていることを一度確認してください。上記と同じところで確認できます。そして、同じ画面で、左上にある三つの平行線（ナビゲーションメニュー）になっているボタンをクリックし、以下の画面が出てきます。

<img width="292" alt="Screen Shot 2019-12-18 at 7.10.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539492/d1f0ceac-0580-b4d7-8d7e-6f208001eea5.png">

下の方にスクロールして、「Vision」と書いてあるボタンをホバーし、「データセット」をクリック。

<img width="416" alt="Screen Shot 2019-12-18 at 7.12.08.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539492/bd477683-3374-256b-0149-7ceace98136d.png">

そして、以下の画面が出てくると思いますが、「AUTOML APIを有効にする」をクリックし、しばらく待ったら、有効になっているはずです。これでCloud Vision APIは使えるようになりました！

<img width="519" alt="Screen Shot 2019-12-18 at 7.16.13.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539492/3033198f-23f8-0722-3e43-e9dbc263a501.png">

### 認証キーを取得

PythonでAPIを呼ぶときに、認証ファイルが必要となります。なので、発行しましょう！「メニュー」をクリックし、以下のように「APIとサービス」ボタンをクリックし、「認証情報」をクリック。

<img width="476" alt="Screen Shot 2019-12-19 at 0.49.30.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539492/4cdab960-10b1-a24a-222b-0b26bab999e8.png">

このような画面が出てくると思いますが、「認証情報を作成」をクリックします。そして、出てきた選択肢から「サービス　アカウント　キー」を選択します。

<img width="536" alt="Screen Shot 2019-12-19 at 0.51.44.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539492/d4110a91-24c9-3424-d9c8-86d825c2b364.png">

サービスアカウントを選択するときに、基本的には自分のデフォルトのアカウントでいいんですが、新しいサービスアカウントを作っても大丈夫なはずです。「キーのタイプ」はそのままJSON形式にし、作成とクリックします。そうすると、サービスキーがダウンロードされます。このキーを任意のところに保存し、次のステップに進みましょう！

<img width="502" alt="Screen Shot 2019-12-19 at 0.53.31.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539492/6aa07c36-1222-34a1-64d2-efc60054cd22.png">

# Pythonで呼びます

### 環境変数の設定

ダウンロードしたサービスキーは、Cloud Vision APIを呼ぶときに必要になります。プログラムから簡単に呼び出せるようにするために、環境変数として設定するのがいいです。保存したサービスキーのパスを```GOOGLE_APPLICATION_CREDENTIALS```と言う環境変数として設定してください。MacかLinuxの場合では、以下のようなコマンドで設定できます。

```console
export GOOGLE_APPLICATION_CREDENTIALS="[サービスキーのパス]"
```

### 任意の画像をOCRでアノテーション

自分のPython環境を用意し、Cloud Vision APIのライブラリをインストールします。```pip```で簡単にインストールできます。

```console
pip install --upgrade google-cloud-vision
```

インストールが完了したら、自分がアノテーションしたい画像を適当に用意しましょう！今回の例としては、以下の画像をアノテーション対象とします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539492/a34b9459-f015-1aee-f23a-e8da643bb182.png)
この画像を```test.png```として、これから書くPythonのプログラムと同じディレクトリに保存します。Cloud Vision APIは、基本的に、ローカルの画像、リモートの画像の両方をアノテーションすることができるが、今回はローカルの画像に対してアノテーションをすることにします。

いよいよプログラムを書きましょう！以下のようにプログラムを書きます！プログラムの参考としては、公式の[サイト](https://cloud.google.com/vision/docs/ocr#vision_text_detection-python)から引っ張ってきて、編集を加えました。コードを読みやすくするために、コメントを付けました。大まかな流れとしては、APIクライアントを初期化し、画像を読み込み、APIに送信。帰ってきた結果を辞書型に変換し、JSONファイルとして保存。

```python:main.py
import json
import io
from google.cloud import vision


def detect_text(path):
    """画像でのテキストを認識"""
    # クライアントを初期化
    client = vision.ImageAnnotatorClient()

    # 指定された画像を開く
    with io.open(path, 'rb') as image_file:
        content = image_file.read()

    # 画像をクライアントに指定（base64）
    image = vision.types.Image(content=content)

    # APIに画像を送信し、結果を格納する
    response = client.text_detection(image=image)

    # アノテーション結果を格納
    # text：response.text_annotations：帰ってきたアノテーション情報
    # text.description：アノテーションされたテキスト
    # text.bounding_poly.vertices：バウンディングボックスの座標
    texts = response.text_annotations

    # 辞書としてアノテーション結果を格納
    results = []
    for text in texts:
        temp = {
            'description': text.description,
            'vertices': [(vertex.x, vertex.y) for vertex in text.bounding_poly.vertices]
        }

        results.append(temp)

    # アノテーション結果をJSONファイルとして格納
    with open('./result.json', 'w') as json_file:
        json.dump(results, json_file, ensure_ascii=False, indent=2)

    return results


print(detect_text('./test.png'))

```

書き出したJSONファイルは以下のようになります。

```json:result.json
[
  {
    "description": "渋谷大和\nShibuya Yamato\n)相模原方面は\nTo Sagamihara\n129\n相模原\nSagamihara\n129\n左車線\n246\nGo to Left Lane\n550m\n",
    "vertices": [
      [
        68,
        151
      ],
      [
        338,
        151
      ],
      [
        338,
        256
      ],
      [
        68,
        256
      ]
    ]
  },
  {
    "description": "渋",
    "vertices": [
      [
        269,
        151
      ],
      [
        281,
        151
      ],
      [
        281,
        163
      ],
      [
        269,
        163
      ]
    ]
  },
  {
    "description": "谷",
    "vertices": [
      [
        286,
        152
      ],
      [
        298,
        152
      ],
      [
        298,
        164
      ],
      [
        286,
        164
      ]
    ]
  },
  （省略）
]

```

# 結果を文章毎に分ける

上記の```result.json```をみると、わかると思いますが、帰ってきたアノテーション結果としては、最初の要素は全体の文章が入ったアノテーション結果になっていますが、その後の要素は、認識した**単語**になっていることがわかりました。つまり、アノテーション結果は、**文章毎に分けていない**状況になっています。

そのため、文章毎に分ける処理を自分で作らなければなりません。ここで、自分が考えたのは、最初のアノテーション要素である全ての文章を手がかりとして、文章毎のアノテーション結果を作ります。上記の```result.json```を読むと、最初の要素のテキスト情報は以下のようになっています。

```
"渋谷大和\nShibuya Yamato\n)相模原方面は\nTo Sagamihara\n129\n相模原\nSagamihara\n129\n左車線\n246\nGo to Left Lane\n550m\n"
```

ここでわかったのは、文章自体は```\n```（改行キャラクター）で区切っています。このように、とりあえずこの最初の要素のアノテーションを```\n```で分割し、参考リストとします。処理としては、次のアノテーション要素をある変数に順次に追加し、参考リストで得られた文章と順次に追加した変数がマッチすれば、文章毎のアノテーション結果に変換することができます。保存するときも、一つの文章になっている各単語のバウンディングボックスの座標をまとめることができます。その関数を以下のように示します。

```python:main.py
...

def merge_annotations(save_path):
    # 保存したアノテーションをロード
    annotations = []
    with open(save_path) as json_file:
        annotations = json.load(json_file)

    # 全体のアノテーション結果を分割し、参考文章を取得
    sentences = annotations[0]['description'].split("\n")

    # 「今の文章、座標、文章のインデックス」と言う変数を初期化
    temp_sentence = ""
    temp_vertices = []
    cur_index = 0

    # 2番目のアノテーション結果から以下のループを行う
    results = []
    for annotation in annotations[1:]:
        # 「今の文章、座標」に、ループしているアノテーション結果（単語、座標）を後ろから追加
        temp_sentence += annotation['description']
        temp_vertices.append(annotation['vertices'])

        # もし現在の「文章インデックス」で指定する参考文章が「今の文章」とマッチすれば、文章情報を格納する
        # ここでスペースを削除する理由としては、Cloud Vision APIでスペースを入れているかどうかは抽出した単語から判断できないから
        if sentences[cur_index].replace(' ', '') == temp_sentence:
            temp_object = {
                'description': sentences[cur_index],
                'vertices': temp_vertices
            }

            results.append(temp_object)

            # 「今の文章、座標」と言う変数を初期化、「文章のインデックス」を1で足す
            temp_sentence = ""
            temp_vertices = []
            cur_index += 1

            # アノテーション結果をJSONファイルとして格納
            with open('./result_cleaned.json', 'w') as json_file:
                json.dump(results, json_file, ensure_ascii=False, indent=2)

    return results


print(detect_text('./test.png'))
print(merge_annotations('./result.json'))
```

結果として、文章毎のアノテーションが得られて、それに対するバウンディングボックスの座標も得られました！

```
{
    "description": "渋谷大和",
    "vertices": [
      [
        [
          269,
          151
        ],
        [
          281,
          151
        ],
        [
          281,
          163
        ],
        [
          269,
          163
        ]
      ],
      ...
    ]
  },
  {
    "description": "Shibuya Yamato",
    "vertices": [
      [
        [
          269,
          166
        ],
        [
          298,
          167
        ],
        [
          298,
          175
        ],
        [
          269,
          174
        ]
      ],
      ...
    ]
  },
  ...
```

# バウンディングボックス

ここで、得られた文章のアノテーションに対するバウンディングボックスをやっぱり描画したいですね。しかし、各文章に対してまとめた座標は必ずしも4点になっていないし、文章の構成単語からのバウンディングボックスをどのように一つの文章のバウンディングボックスとして描画させるか？のが今回のチュートリアルのポイントですね。

気づいている方は気づくと思いますが、この問題では「凸包」の集合を結び付いた多角形を作れば解決ですね。「凸包」とは、直感的に言うと、ある複数の点集合が、ある”領域”を占めている場合、その領域を描く境界線の点集合のことです（この点の集合も元々の点の集合のサブセットになります）。この問題を解くと、各文章のバウンディングボックス（必ずしもバウンディングボックスにはならないが）を描画できるようになります！凸包自体に関してもう少し詳しく調べたい方は、[ウィキペディア](https://ja.wikipedia.org/wiki/%E5%87%B8%E5%8C%85)のリンクを貼っときます。

凸包問題は、そもそも情報の分野でも有名な問題で、いくつかのアルゴリズムで解けますが、今回のチュートリアルでそれがメインではないので、簡単に済ませたいですね。ここでは、[Shapely](https://github.com/Toblerity/Shapely)と言う幾何学的なオブジェクトを分析・編集するためのライブラリを使います。早速インストールしましょう。

```
pip install shapely
```

今回の処理の流れとしては、Shapelyのオブジェクトである```MultiPoint```として、各文章のバウンディングボックス座標の集合を格納し、それを用いて、```convex_hull```で凸包を構成する多角形を抽出します。そして、多角形を元の画像に描画させますが、今回は```Pillow```の```ImageDraw```を使います。なので、これもインストールしましょう。

```
pip install Pillow
```

早速コードを提示します！

```python:main.py
...(import文)
from shapely.geometry import MultiPoint
from PIL import Image, ImageDraw

...(detect_path, merge_annotationsの関数)

def draw_boundaries(annotations, image_path, out_path):
    # 凸包を取得
    convex_hulls = []
    for annotation in annotations:
        # 座標の集合を一個のリストとしてまとめる
        vertices = annotation['vertices']

        merged_vertices = []
        for vertex_set in vertices:
            for vertex in vertex_set:
                merged_vertices.append(vertex)

        # 点集合からMultiPointにして、それの凸包を求め、その凸包を構成する座標の集合を取得
        convex_hull = MultiPoint(
            merged_vertices).convex_hull.exterior.coords
        convex_hulls.append(list(convex_hull))

    # 各文章のバウンディングボックスを描画
    source_img = Image.open(image_path).convert("RGB")
    draw = ImageDraw.Draw(source_img)

    for convex_hull in convex_hulls:
        draw.line(convex_hull, fill="red", width=1)

    # 描画した画像を保存
    source_img.save(out_path, "PNG")

    return convex_hulls


detect_text('./test.png')
annotations = merge_annotations('./result.json')
draw_boundaries(annotations, './test.png', './test_bb.png')
```

test_bb.pngを確認すると…

![test_bb.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539492/00a2e438-8cf8-97c2-bed0-3f24e7a31f13.png)

**できたー！**

# 終わりに
このように、Cloud Vision APIでOCRを行い、しかも文章毎のバウンディングボックスまで抽出することができました！今回のチュートリアルがご自身のデータマイニング作業などに役に立てればなと思っています！不明な点がありましたら、是非コメントお願いします！