レシートOCRのWebサービスを作ったみた(バックエンド編 Part1)

# シリーズ構成
1. アーキテクチャ編 https://qiita.com/Himajiro2/items/1d457cb4e3b8873df7fb
2. バックエンド編 Part1 (本記事)
3. バックエンド編 Part2 (編集中)
4. バックエンド編 Part3 (編集中)
5. フロント編 (編集中)


## 本記事について
本記事ではレシートOCRサービスのバックエンドのコードを解説する
本サービスではバックエンドを3つのサービスに分割して1つ1つをCloud Runで構築している
本記事ではフロントからのレシート画像のアップロードをトリガーに画像の前処理をするサービスを解説

## 前処理の必要性
フロントはWebRTCを使いレシートを撮影し画像をGoocle Cloud Storage for Firebaseにアップロードしている
画像をそのままGoogle Cloud Vision APIに渡すと想定した精度が出なかったため, 以下2つの前処理を施して解析精度の向上を図っている.
なお, 画像前処理はopenCVを用いている.
1. グレースケール変換
2. リサンプリング（バイリニア補完）

## グレースケール変換
画像処理やOCRでよく使われる手法で， RGBの 24bitの情報を8bitに削減する.
レシートはもともと白黒だが, グレースケース変換をしたほうがOCR精度が若干高かったため採用している.
(恐らく, 撮影時の照明などの光によって受ける影響を減らすことができていると考える.)

## リサンプリング（バイリニア補完）
グレースケース変換だけでは, 期待する精度は出なかった. WebRTCからキャプチャした画像は1280x720pxだったためと考える. そのため, リサンプリングをする.
リサンプリングは画像を拡縮する際に使われ, 今回は拡大する.
リサンプリングには様々な手法があり, 単純な最近傍法やGAN(敵対的ネットワーク)を使うようなものまであるもよう. 今回はバイリニア補完を用いる.
なお, 今回試した限りではバイリニア補完すると画像サイズが3~4倍になった.
(バケット画像の削除などはサービス内では処理していないため, GCSのライフサイクルなどを使って運用費を抑えるなどの工夫が必要)

#### 最近傍法
周辺で最も近くにある画素情報をそのまま補完に使う方法. 計算が少ないため高速だが画像の劣化に繋がることがある
(参考) https://algorithm.joho.info/image-processing/nearest-neighbor-linear-interpolation/

#### バイリニア補完
周辺4ピクセル分の画素情報を用いて補完する方法. 最近傍法より処理は多いが, 単一画素はなく周辺複数の画素を参照するため画像の劣化が少ない.
(参考) https://algorithm.joho.info/image-processing/bi-linear-interpolation/

#### 参考情報
画像のリサンプリング手法の概要や手法の比較は, 以下の記事が非常に参考になった.
https://www.nttpc.co.jp/gpu/article/technical02.html

## サービスの流れ
1. フロントからoriginalバケットにレシート画像がアップされる
2. Eventarcによりサービスが起動する
3. グレースケール変換/リサンプリング処理をし, resampledバケットに保存する
<br>(後続の[vision-formatter]サービスが, 3をトリガーに起動する)

## サービスの構成
サービス名: image-preprocess
ディレクトリ構成:
┣ house-account
&nbsp;&nbsp; └ image-preprocess
&nbsp;&nbsp; &nbsp;&nbsp; &nbsp;&nbsp; └ main.py
&nbsp;&nbsp; &nbsp;&nbsp; &nbsp;&nbsp; └ resampling.py

## モジュールの要概
- main.py: Eventarcからバケット情報や画像のパスなどの情報を取得する
- resampled.py: 画像を前処理しresampledバケットに保存する

### main
```main.py
import os
from flask import Flask, request
from cloudevents.http import from_http

import resample

app = Flask(__name__)

@app.route('/', methods=['POST'])
def index():
    cloud_event = from_http(request.headers, request.get_data())
    uri, bucket, name = read_event_data(cloud_event)

    if not ("jpg" in name or "png" in name):
        return ("Invalid image format. Supported jpg/png", 500)

    try:
        resample.preprocess_bi_linear(uri, bucket, name)
        return ("OK", 200)
    except Exception as e:
        print(f"error: {e}")
        return ("", 500)
    
    return ("", 500)

def read_event_data(cloud_event):
    event_data = cloud_event.data
    type = cloud_event['type']
    # Handling new and old AuditLog types.
    if type == 'google.cloud.audit.log.v1.written' or type == 'com.google.cloud.auditlog.event':
        protoPayload = event_data['protoPayload']
        resourceName = protoPayload['resourceName']
        tokens = resourceName.split('/')
        return tokens[3], tokens[5]
    return event_data['mediaLink'], event_data['bucket'], event_data['name']


if __name__ == "__main__":
    app.run(debug=True, host='0.0.0.0', port=int(os.environ.get('PORT', 8080)))
```

cloudeventsモジュールにより, Cloud Runが受け取ったHTTPリクエストからCloudEvents形式のデータを取り出しが容易になる. これにより, GCSのパス/バケット名/ファイル名などを取得している.


### 画像前処理モジュール
```resampled.py
import cv2
import datetime

from google.cloud import storage

def preprocess_bi_linear(uri, bucket, name):
    // GCSの参照やパスなど準備
    client = storage.Client()
    original_bucket = client.bucket(bucket)
    resampled_bucket = client.bucket('house-account-resampled')
    dir_name = name.split('/')[0]
    original_file_name = name.split('/')[-1]
    resampled_file_name = datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=9))).strftime('%Y%m%d%H%M%S') + '.jpg'

    print(f"uri: {uri}")
    print(f"bucket: {bucket}")
    print(f"name: {name}")
    print(f"original_file_name: {original_file_name}")

    // originalバケットの画像をダウンロードし, メモリに展開
    blob = original_bucket.blob(name)
    blob.download_to_filename(original_file_name)
    img = cv2.imread(original_file_name)

    // グレースケール変換
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

    // バイリニア補完
    dst = cv2.resize(
        gray,
        (gray.shape[1]*2, gray.shape[0]*2),
        interpolation=cv2.INTER_LINEAR
    )

    // 画像をGCSにアップロード
    cv2.imwrite(resampled_file_name, dst)
    blob = resampled_bucket.blob(dir_name + '/' + resampled_file_name)
    print(f"blob: {blob}")
    blob.upload_from_filename(resampled_file_name, content_type='image/jpeg')
    print(f"Upload is success")
    return
```

## まとめ
これでフロント→前処理までがつながった.