レシートのOCR・日付の抽出編（Python）


# 概要

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

通常のアプローチと泥臭いアプローチの2つをお見せします。


## 前提となる記事

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


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

### 追記：テキストをただ順に抽出したいだけの場合

テキストをただ順に抽出したいだけの場合は上のようなごつい関数でなくても良くて、text_annotationsの最初の要素に全文が入っているようです。

```py
# 下記にOCRの文章がまるっと入っている
response.text_annotations[0].description

# それ以降は文字とそのBoundingBoxが入る
n = 1 # n > 0
## 文字列（単語）
response.text_annotations[n].description
## 文字列（位置）
response.text_annotations[n].bounding_poly
```

https://i-was-a-ki.hatenablog.com/entry/2020/02/29/175753


## 正規表現を用いた日付の抽出

日付には多様な表現があるため適切な正規表現を設定する必要があります。

https://qiita.com/shoku-pan/items/7045ca15f79bcc97f2d3


上記の記事を参考にして

```
pattern = r'[12]\d{3}[/\-年 ](0?[1-9]|1[0-2])[/\-月 ]([12][0-9]|3[01]|0?[0-9])日?'
```

が良さそうとなりました。主たる変更内容・意図は下記の通りです。

- 文字列末指定の解除（OCRの結果から抜き出したいため）
- マッチさせる順番の変更（OR結合は左からマッチさせるので2022/11/23をマッチさせたいときに先に2022/11/2にマッチするのを防ぐように順序を変える）


この結果、関数は下記のようになります。キャンペーンの日付などが載ることもあると思うので最初に見つけた日付を返すようにします。

```py
def extract_date(lines):
    pattern = r'[12]\d{3}[/\-年 ](0?[1-9]|1[0-2])[/\-月 ]([12][0-9]|3[01]|0?[0-9])(日?)'
    for line in lines:
        texts = [i[2] for i in line]
        texts = ''.join(texts)
        if re.search(pattern,texts):
            return re.search(pattern,texts).group(0).replace(' ','/')
    # 該当がない場合    
    return ''
```

## 正規表現でカバーできないケースについて

上記で解決したかと思いきや、OCRの結果によっては日付が抜き出せないことが判明しました。

例えば、下記のようなケースでは何故か「年、月」などの単語がOCRできていません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/188863/31a5109b-0e86-250d-f5b3-41eda4122adf.png)

OCRをチューニングするにはちょっと知識と課金の問題があったのでここでは泥臭い解決策をとります。


### 解決策のアイデア


- word単位では少なくとも年と月と日は取得できているとする
- 特定の年（今回は2022年）と一致する表記がないか確認し、その後ろにあるいくつかのwordをつなぎ合わせたものから正規表現で日付にマッチングするものを抽出する


### wordブロック毎のOCR文字列取得（半角スペースを挿入）

最初上図の画像からwordのみを抽出した際に、日付が`129`と出てきて`12/9`か`1/29`か判別できない問題がありました。

しかし、上記の画像を確認するに正解例1と29の間には比較的広いスペースが存在するため、**文字の間に文字幅の2倍より長い空間がある場合は半角スペースを挿入する**プログラムで単語を抜き出すことで`12 9`のような表記になるようにします。

ということでブロックごとの文字列をスペース付きで抽出する関数を作成しました。

```python
def symbol_width(symbol):
    return symbol.bounding_box.vertices[1].x - symbol.bounding_box.vertices[0].x

def extract_words(response):
    """
    words単位でテキストをリスト化。スペースが空いている場合は半角スペースを挿入
    """
    document = response.full_text_annotation
    words = []
    for page in document.pages:
        for block in page.blocks:
            for paragraph in block.paragraphs:    
                for word in paragraph.words:
                    # word 単位で処理
                    texts = ''
                    last_left_x = - 1e6
                    # word内の文字を結合
                    for symbol in word.symbols: 
                        left_x = symbol.bounding_box.vertices[0].x
                        # 行間が文字の横幅より一定量大きいならば、半角スペースを挿入
                        if left_x - last_left_x > symbol_width(symbol)*2:
                            texts += ' ' + symbol.text
                        else:
                            texts += symbol.text
                        last_left_x = left_x
                    words.append(texts[1:]) # 先頭の半角スペースを排除して抽出
    return words
```

### 日付の抽出


- 2022または2021と一致するwordのテキストを探す
- 後続のwordのうち、日付に該当しそうなものを探す
    - 後続の5個程度のwordを結合
    - 数字のみが必要なので数字のみを抜き出す
    - 正規表現で日付に該当しそうなものを調べる

と言う流れでやっていきます。

話すと長いのですがOCR結果に下記のようなブレがあり、これら全てに対応しようとした結果周りくどい感じになってしまいました。
`['2022', '年', '3', '月','9','日']`
`['2022', '3', '9']`
`['2022', '3 9']`
`['2022 3 9']`




```python
def search_monthdate_string(wordlist):
    """
    stringのリストから正規表現でXX/YYとなりうる最初の月日を検索。ない場合は空の文字列を返す。
    """
    newlist = []
    for word in wordlist:
        word_ = ' '.join(re.findall(r"\d+", word))
        if word_:
            newlist.append(word_)
    
    monthdate_candidate = '/'.join(newlist).replace(' ','/')
    #print(monthdate_candidate)
    monthdate_match = re.search(r'(0?[1-9]|1[0-2])[/]([12][0-9]|3[01]|0?[0-9])($|/)', monthdate_candidate)
    if monthdate_match:
        #print(monthdate_match)
        return '/'.join(re.findall(r"\d+", monthdate_match.group()))
    else:
        return ''

    
def find_date_from_year(response, yearlist=['2021','2022']):
    """
    特定の年のパターンに合致する日付を返す。なければからの文字列を返す。
    """
    words = extract_words(response)
    year_matched = ''
    for i in range(len(words)):
        word = words[i]
        # そのまま日付にマッチングする場合
        if matching_date_pattern(word):
            return matching_date_pattern(word).replace(' ','/')
        # 年だけにマッチングする場合
        if word in yearlist:
            searched_date = search_monthdate_string(words[i+1:i+6]) # 月日なども考慮
            if searched_date:
                return word+'/'+searched_date
            else:
                return searched_date
            # 何もなければ空の文字列を返す
    return ''
```

## 日付抽出、全体の流れ

ざっとまとめるとこんな感じになります。

1. 行ごとに文字列を抽出
1. 行ごとに正規表現で年月日に合致する表記を抽出
1. 上記で年月日が得られなかった場合、wordごとに文字列を再度抽出
1. wordから指定した年の日付になりうる数値パターンがないか検索
1. 得られた日付を返す。

一応ここまでやってようやくすべてのレシートから日付を抽出できるようになりました。  
多分仕事でやるならOCRの方を改善しようとするでしょうが日曜プログラムなのでこのようなアプローチとなりました。

```python
def search_date(response):
    lines = get_sorted_lines(response)
    date = extract_date(lines)
    if date:
        return date
    else:
        date = find_date_from_year(response)
        return date
```