レシートのOCR・店名編（Python）

# 概要

Google Vision APIを使ったOCR結果をもとにレシートの店名を抽出します。

## 前提となる記事

下記の記事を前提としています。


https://qiita.com/shoku-pan/items/bf5645894803769edc72#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB

データフローは下記のようになっており、 

```mermaid
graph LR
 image -->|OCRの実行| OCR_object
 OCR_object -->|必要なデータを抽出| text_and_position_data
```

データの抽出には下記の関数を用いました。以降はこの生成物である`lines`を前提とします。
(ざっくりいうと`[x,y,text,symbol.boundingbox]`を格納したリストのリストです。)

```py
def get_sorted_lines(response,threshold = 5):
    """Boundingboxの左上の位置を参考に行ごとの文章にParseする

    Args:
        response (_type_): VisionのOCR結果のObject
        threshold (int, optional): 同じ列だと判定するしきい値

    Returns:
        line: list of [x,y,text,symbol.boundingbox]
    """
    # 1. テキスト抽出とソート
    document = response.full_text_annotation
    bounds = []
    for page in document.pages:
        for block in page.blocks:
            for paragraph in block.paragraphs:
                for word in paragraph.words:
                    for symbol in word.symbols: #左上のBBOXの情報をx,yに集約
                        x = symbol.bounding_box.vertices[0].x
                        y = symbol.bounding_box.vertices[0].y
                        text = symbol.text
                        bounds.append([x, y, text, symbol.bounding_box])
    bounds.sort(key=lambda x: x[1])
    # 2. 同じ高さのものをまとめる
    old_y = -1
    line = []
    lines = []
    for bound in bounds:
        x = bound[0]
        y = bound[1]
        if old_y == -1:
            old_y = y
        elif old_y-threshold <= y <= old_y+threshold:
            old_y = y
        else:
            old_y = -1
            line.sort(key=lambda x: x[0])
            lines.append(line)
            line = []
        line.append(bound)
    line.sort(key=lambda x: x[0])
    lines.append(line)
    return lines
```



# 方策

「前提」の部分で行ごとにテキストとBoundingBoxが抽出し終わっているとします。

ここでは下記の2つの仮定を置きます。

- 店の名前は上から数行の間に入っているはず
- 店の名前は一番大きくレシートに載っているはず

ここから、下記の方策で店名を抽出します。

- 数行分のテキストとBoundingBoxの縦幅を取得
- それを縦幅順に並び替えて上位のテキストを店名として採用

## BoundingBoxの高さ計算

`symbol.bounding_box`のvertices(頂点)には4つの頂点のリストが入っているのでそこからyの最大値と最小値を抜いてきて差分を取れば高さが計算できます。

```python
def calc_bbox_height(bbx):
    ymax = 0
    ymin = 1e9
    for vt in bbx.vertices:
        ymax = max(ymax,vt.y)
        ymin = min(ymin,vt.y)
    return ymax - ymin
```

いや全然もっとスマートに書けますね。

```python
def calc_bbox_height(bbx):
    ylist = [vt.y for vt in bbx.vertices]
    return max(ylist) - min(ylist)
```

## 縦幅が大きい順にソートして店名を抽出

本来は先程の関数を使ってBoundingBoxの高さを評価してソートしておしまいなのですが、
**レシートによっては「領収書」や「クレジットカード決済」みたいな文字が一番大きかったりするので**それを一応回避するようにしてます。


```python
# 上からN行抜き出し、テキストの縦幅順にソートして一番文字が大きいものを抽出する
def get_shop_name(lines, checking_line_num = 5):
    heights_and_texts = []
    for i in range(checking_line_num):
        line = lines[i]
        texts = [i[2] for i in line]
        texts = ''.join(texts)
        bbx = [i[3] for i in line]
        height = []
        for bb in bbx:
            height.append(calc_bbox_height(bb))
        average_height = sum(height)/len(height)
        heights_and_texts.append([average_height,texts])

    # ソートして一番大きなテキストを返す
    biggest_bbox = sorted(heights_and_texts, key=lambda x:x[0])[-1]
    
    # レイアウトによっては領収書の文字が一番大きかったりするので慈善の策
    if "領収" in biggest_bbox[1] or "クレジット" in biggest_bbox[1]:
        return sorted(heights_and_texts, key=lambda x:x[0])[-2][1]
    else:
        return biggest_bbox[1]
```


## 結果どうなるか

店の名前がきちんとレシートに大きく載って、フォントも変に凝っていないものがよく検出できます。
逆に店のロゴが大きく、フォントが分かりづらい場合はOCRで文字読み込みに失敗しているのでうまく行かないです。


検知が得意な店例

- イトーヨーカドー
- KALDI
- おかしのまちおか
- マツモトキヨシ

検知が苦手な店例

- ハナマサ： フォントが特殊でOCR失敗する
- いなげや： いなげやの前のマークが文字として誤認識されて面倒
- ミスド： ミスドの文字より支店名の方がでかい
- その他色々...


## 今後の改善（やるとはいっていない）

店名の検出はOCR結果から読み取るのは結構きついものがあると思いました。

- 表記ブレに関しては実在する店名との誤り訂正などをおこなって回避する
- そもそもOCRでうまく認識できないようなロゴのケースなどはロゴをまるごと画像として学習して分類する

などは市販のアプリでは実装されていそうな感じがします。

最初はロゴを無視して地の文の店名を読もうとしていたのですが意外と地の文の店名記載がない店も多かったりして諦めました。結構つらそうな雰囲気です。

## 関連記事

後ほどまとめます。