Google Cloud Vision APIでドキュメントの向き判定をする

# なにをしたいか
ドキュメントをスキャンor撮影した画像があるとする。それぞれの画像はドキュメントの正しい向きに対してバラバラの向きになってしまっている。これをGCPのVision APIで取得できる情報を使って正しい向きに回転してやりたい。
ちなみに、フリーのOCRであるTesseract OCRでは[PSM(Page Segmentation Model)に`0`や`2`を設定することでテキストの傾斜角度を調べることが可能](https://valmore.work/how-to-use-tesseract4-with-python/)。一方、Google Cloud Vision APIでは調べた限りそのようなオプションは見つからなかったので、自前で実装することにする。
**※なお、本記事では横書きのドキュメントのみを対象とし、縦書きのドキュメントは考慮しない。**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228689/4bdce149-a958-ad76-b9a6-ae7d3a066fc8.png)

# 目次
<!-- タイトルとアンカー名を編集 -->
1. [Google Cloud Vision APIで得られる情報](#ocr-info)
1. [向き判定アルゴリズム](#algorithm)
1. [実装](#implementation)
1. [参考文献](#reference)

<!-- 各チャプター -->
<a id="#ocr-info"></a>
# Google Cloud Vision APIで得られる情報
テキスト検出機能として`DOCUMENT_TEXT_DETECTION`を使った場合、以下のような階層構造をもったテキスト情報が得られる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228689/7859380c-6436-4406-399f-01064c90297b.png)
([Vision API OCR事始め(2)：検出されたテキストの階層構造（fullTextAnnotation）](https://technodaifuku.blogspot.com/2020/10/vision-api-ocr2fulltextannotation.html#c2)より引用)

[Googleのドキュメント](https://cloud.google.com/vision/docs/fulltext-annotations?hl=ja)によれば、それぞれの階層は以下のような意味をもつようだ。
>fullTextAnnotation は、画像から抽出された UTF-8 テキストを階層構造で表現したレスポンスで、ページ→ブロック→段落→語→記号のように編成されています。
>
>- Page は、ブロックの集まりに、ページについてのメタ情報、つまりサイズ、解像度（X 解像度と Y 解像度が違う場合がある）を付加したものです。
>
>- Block は、ページの 1 つの「論理的」要素を表します。たとえば、テキストで埋め尽くされている領域や、列と列の間にある図や区切りなどです。テキスト ブロックとテーブルブロックには、テキスト抽出に必要な主要な情報が含まれています。
>
>- Paragraph は、順序付けられた単語列を表すテキストの構造単位です。デフォルトで単語は、単語区切りで区切られているものとみなされます。
>
>- Word は、テキストの最小単位です。これは、記号の配列として表記されます。
>
>- Symbol は、文字または句読点記号を表します。

例えば、今回使う画像だと以下のような階層構造に分類される(青枠がBlock、緑枠がParagraph、橙色の枠がWord)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228689/7a751094-49fd-fe48-b621-0fd4e608b1e6.png)

またBlock, Paragraph, Word, Symbolの各オブジェクトは画像内での位置を示す`bounding_box`の情報を持っている。つまりWordの`bounding_box`は単語毎(ex. 毎日, 暮らし, ステキ, アイデア)の位置を示し、Symbolの`bounding_box`は文字毎(ex. 毎, 日, 暮, ら, し, ...)の位置を示す。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228689/2c552d41-daa0-32d1-c2c9-26fcd6b2b2c9.png)

<a id="#algorithm"></a>
# 向き判定アルゴリズム
上記の階層構造のうち、WordとSymbolが今回は重要な要素となる。すなわち、word内で文字がどの方向で読まれたかを以下の方法で推定する。
- 文書内の各word内の最初のsymbolの`bounding_box`$B_0\hspace{0.1ex}$と、最後のsymbolの`bounding_box`$B_{-1}\hspace{0.1ex}$を取得する

- 得られた`bounding_box`の4つの頂点の座標を平均して$B_0, B_{-1}$の重心$C_0=(C_{0x}, C_{0y}), C_{-1}=(C_{-1x}, C_{-1y})$を算出する

- $C_0, C_{-1}$の位置の差分${\delta x=C_{-1x}-C_{0x}, \delta y=C_{-1y}-C_{0y}}\hspace{0.1ex}$を計算し、以下のルールで各wordの読まれた向きを判定
  1. $|\delta x|>|\delta y|\hspace{0.1ex}$であって、かつ$\delta x>0\hspace{0.1ex}$である場合: **0°** (横書きのドキュメントにおいて正しい向き)
  2. $|\delta x|>|\delta y|\hspace{0.1ex}$であって、かつ$\delta x\leq0\hspace{0.1ex}$である場合: **180°**
  3. $|\delta x|\leq|\delta y|\hspace{0.1ex}$であって、かつ$\delta y >0\hspace{0.1ex}$である場合: **90°**
  4. $|\delta x|\leq|\delta y|\hspace{0.1ex}$であって、かつ$\delta y\leq0\hspace{0.1ex}$である場合: **-90°**

- 各wordについて上記の処理を実行し、[0°, 90°, -90°, 180°]のうち最も多かった向きを採用する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228689/29b1720e-cc74-b43a-f88f-f5581eefc97c.png)



<a id="#implementation"></a>
# 実装
```python:向き判定コード
import os
from io import BytesIO
from tqdm import tqdm
import numpy as np
from google.cloud import vision
from PIL import Image
import matplotlib.pyplot as plt

os.chdir(os.path.dirname(__file__))


GOOGLE_CREDENTIALS_PATH = '/path/to/credential'
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = GOOGLE_CREDENTIALS_PATH
vision_client = vision.ImageAnnotatorClient()

impath = '/path/to/image'

with open(impath, "rb") as f :
    content = f.read()
    
img = Image.open(BytesIO(content))
inp = vision.Image(content=content)
response = vision_client.document_text_detection(
    image=inp,
    image_context={'language_hints': ['ja']}
    )


document = response.full_text_annotation
text_blocks = document.pages[0].blocks

# [rotate 0, rotate -90, rotate +90, rotate +180]
rotate_flgs = [0, 0, 0, 0] 
rotate_angles = [0, -90, 90, 180]

for i, bl in enumerate(tqdm(text_blocks)):
    paragraph = bl.paragraphs
    for p in paragraph:
        for w in p.words: 
            symbol_centroids = []
            for s in w.symbols:
                symbol_bbox = np.array([[b.x, b.y]
                                      for b in s.bounding_box.vertices])
                symbol_centroid = np.mean(symbol_bbox, axis=0)
                symbol_centroids.append(symbol_centroid)
            
            symbol_centroids = np.array(symbol_centroids)
            if len(symbol_centroids) > 1:
                c_delta = symbol_centroids[-1, :] - symbol_centroids[0, :]
                if abs(c_delta[0]) > abs(c_delta[1]):
                    if c_delta[0] > 0:
                        rotate_flgs[0] += 1
                    elif c_delta[0] < 0:
                        rotate_flgs[3] += 1
                elif abs(c_delta[0]) < abs(c_delta[1]):
                    if c_delta[1] > 0:
                        rotate_flgs[2] += 1
                    elif c_delta[1] < 0:
                        rotate_flgs[1] += 1

rotate_angle = rotate_angles[np.argmax(rotate_flgs)]
print(rotate_angle)

plt.figure(dpi=200)
plt.title(f'inferred angle : {rotate_angle}')
plt.imshow(img)

```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228689/b0cb7fcb-cb52-f92b-9471-89e9fff466c0.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228689/c7b8cb32-0950-3631-cb87-1a861123ec5e.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228689/de8cef03-db7d-8584-c701-ba6143ac6141.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/228689/21589ff3-e376-a00e-edcb-458d891d0b13.png)

<a id="#reference"></a>
# 参考文献
- [Vision API OCR事始め(2)：検出されたテキストの階層構造（fullTextAnnotation）](https://technodaifuku.blogspot.com/2020/10/vision-api-ocr2fulltextannotation.html#c2)
- [高密度ドキュメントのテキスト検出のチュートリアル](https://cloud.google.com/vision/docs/fulltext-annotations?hl=ja)