akatak blog

プログラム初心者の50代ビジネスマンがセカンドキャリアを目指して働きながらPythonを中心に独学していましたが、昨年IT系企業に転職。新規事業開発の仕事をすることになりました。自らの覚え書きや成果物、感じたことなどを脈絡もなく書き連ねるブログです。

名刺OCR API機能の実装(Google Vision API+独自AIモデル+kintoneとの連携)

本ブログの更新をすっかりご無沙汰しておりました。

昨年春にE資格を取得して、昨年4月には前の会社を早期退職しました。 フリーランスとしてデータ分析やAI案件獲得を目指していましたが、実務経験がないことが足枷となり、なかなか難しいなと感じていたところ、縁あって昨年夏からIT系企業に勤めています。

また、昨年後半には、経産省主催のAI人材育成プログラムであるAI Quest2021に参加したり、ディープラーニング協会の運営のサポートメンバーをしたり、公私にわたり忙しく、なかなかブログ更新ができておりませんでした(言い訳です)。

そうした中で、社内プロジェクトの一環で掲題に関する開発を行いましたので、その概要について説明したいと思います。

1. 全体像

名刺アプリ全体像は、上図の通りであり、「名刺管理アプリ(kintone側)」と「名刺OCR-API機能(GCP側)」の2つから構成されています。 言語としては、kintone側がJavascriptGCPGoogle Cloud Platform)側がPythonを利用しています。

そうそう説明が前後してしまいましたが、kintoneとは、サイボウズ社が提供している、webデータベース型の業務アプリ構築クラウドサービスです。いわゆるPaaS(Platform as a Service)に該当するサービスです。さまざまな業種や職種の業務に合わせたオリジナルアプリをノーコードで簡単に作成できますが、標準機能を超えてカスタマイズをしようとするとJavascript(但し、独自性が強いところがあり)を利用する必要があります。

GCP側は以下の機能を使っています。

Vision API

Vision APIは、GCPが提供する機械学習サービスの一種です。 このサービスを利用することで、Googleの持つ画像に関する機械学習モデルを使い、対象となる画像から様々な情報を取得することが可能です。今回は画像からテキストとそのテキストが画像のどの箇所にあるかという位置情報を取得して、利用しています。

Cloud Run

Cloud Run は、いわゆるサーバーレスサービスであり、Docker等のコンテナを実行できる環境です。マシンの構成やトラフィックに応じた自動スケーリングについて心配することなく、サーバーレス HTTP コンテナをデプロイしてスケールするためのフルマネージドなサービスということです。

Cloud Endpoints

Cloud Endpointsは、APIを認証・管理するためのサービスです。

Cloud Storage

Cloud Stroageは、GCPが提供しているオンラインストレージサービスです。Google Driveと比較して、GCPの各サービスとの連携が容易という特徴があります。

Cloud Scheduler

cron のようにジョブの定時実行を行うことのできるサービスです。 任意のHTTPエンドポイントにリクエストを投げることなどが可能です。Cloud Runがサービスを利用していないと停止して、次に呼び出した際にCold Startしてしまうため、立ち上がりまでに時間がかかってしまいます。そこで、本機能を利用して、日中Cloud Runが停止することを避けるために利用しています。

2. 独自AIモデル

GCPではNatural Language APIが提供されていて、その中で「エンティティ分析」という機能があります。具体的には、指定されたテキストに既知のエンティティ(人名、ランドマークなどの固有名詞)が含まれていないかどうかを調べて、それらのエンティティに関する情報を返すサービスです。ただ、名刺の分類で必要な役職やタイトル、部署・住所などが抽出できないし、文章ではなく単語ベースでは精度が悪いので、名刺OCRには適用が難しいので、独自モデルを作成することにしました。

Vision APIでは、画像からテキストを抽出するだけではなく、テキストの位置情報(画像の中でどこにあるかという情報で、テキストを囲む矩形の頂点の位置情報)もデータとして返ってくるので、(双方を利用した方が精度が良いだろうという仮説を元に)双方を利用することにしたものです(体感ではテキストのみを利用したものより精度が良いですが、現時点ではデータが少なく検証までは至っていません😅)。

モデルとしてはテキスト分類でも相応の精度となるCNNモデルを利用しています。CNNだと位置情報を画像化して統合もしやすいのでそうしたメリットもあります。なお、文章等を分類する場合には、辞書を利用して単語毎にTokenizerで分けて数値化するのが通常だと思いますが、名前や会社名・役職等の対象が短いので、文字ベースでテキストを数値化しています。

3. API連携の流れ

  • kintoneの名刺管理アプリ等のアプリケーション側で、名刺画像をbase64形式に変換したうえで、以下の通り設定(json形式)してEndpointsにrequestsを送付します
    • headersとして {'Content-Type': 'application/json'}
    • dataとして {'requests': [{'images': {'content': base64形式に変換した画像 }]}
  • 名刺OCR機能(API)側で処理を行い、その結果を以下の通り返します(json形式)
    {'department': 'xxxx部',
     'email': 'akatak-blog@xxxx.co.jp',
     'fax': '03-xxxx-xxxx',
     'location': '東京都xx区xxx-xxx-xxx,
     'mobile': '03-xxxx-xxxx',
     'organization': '株式会社xxxxxxxx',
     'other': 'xxxx, xx"
     'person': '',
     'tel': '03-xxxx-xxxx',
     'title': 'xx長',
     'web': 'http//www.xxx.co.jp/'}
  • kintoneの名刺管理アプリ等のアプリケーション側で、受け取ったjsonを元に、仕様に基づき適宜データを保存します

4. Google Cloud Platform(GCP)側の処理

  • 画像データ(base64形式)をVision APIに送付します。
  • テキストデータと位置情報が返ってくるので、それらの情報に基づき、位置が近いテキストをひとまとまりのブロックにします。
  • そのブロック毎のテキストに対して、正規表現を利用して、電話番号・ファックス・郵便番号などを抽出します。
  • 抽出されなかったブロック毎のテキストを、訓練済みの独自AIモデルに投入し、分類結果を取得します。
  • 正規表現に基づいた分類結果とAIモデルによる分類結果を統合し、結果を返します。

5. 実際のAIモデル

モデルの実装はPytorchで行っています。まだ、モデルを訓練するにあたり、Vision APIにより位置情報のある名刺データが少ないため、テキストデータのみに基づくモデル(TextCNNModel)で事前学習をしておき、そこで得られたパラメータを初期値として統合モデル(IntegratedCNNModel)Vision APIにより得られたテキスト+位置情報を元にしたデータに元に転移学習を行いました。

class IntegratedCNNModel(nn.Module):

    def __init__(self, vocab_size=10000, emb_size=50, filters=128,
                 text_model_initials=None, requires_grad=True):
        super(IntegratedCNNModel, self).__init__()

        self.filters = filters
        self.embedding = nn.Embedding(num_embeddings=vocab_size,
                                      embedding_dim=emb_size,
                                      padding_idx=0)
        self.text_conv = nn.Conv2d(in_channels=1,
                                   out_channels=filters,
                                   kernel_size=(3, emb_size),
                                   stride=1)
        self.text_fc = nn.Linear(128, 64)

        self.dropout1 = nn.Dropout(0.5)
        self.dropout2 = nn.Dropout(0.25)
        self.conv1 = nn.Conv2d(1, 16, 3, 1)
        self.conv2 = nn.Conv2d(16, 32, 3, 1)
        self.fc1 = nn.Linear(28800, 1280)
        self.fc2 = nn.Linear(1280, 6)

        self.fc_all = nn.Linear(64 + 6, 6)

        if text_model_initials:
            self.embedding.weight = nn.Parameter(text_model_initials['embedding.weight'],
                                                 requires_grad=requires_grad)
            self.text_conv.weight = nn.Parameter(text_model_initials['conv.weight'],
                                                 requires_grad=requires_grad)
            self.text_conv.bias = nn.Parameter(text_model_initials['conv.bias'],
                                               requires_grad=requires_grad)
            self.text_fc.weight = nn.Parameter(text_model_initials['fc1.weight'],
                                               requires_grad=requires_grad)
            self.text_fc.bias = nn.Parameter(text_model_initials['fc1.bias'],
                                             requires_grad=requires_grad)

    def forward(self, in_text, in_pos):
        x1 = self.embedding(in_text)
        x1 = self.text_conv(x1)
        x1 = F.relu(x1)
        x1 = F.max_pool2d(x1, kernel_size=(x1.size()[2], 1))
        x1 = self.dropout1(x1)
        x1 = x1.view(-1, self.filters)
        x1 = self.text_fc(x1)

        x2 = self.conv1(in_pos)
        x2 = F.relu(x2)
        x2 = self.conv2(x2)
        x2 = F.relu(x2)
        x2 = F.max_pool2d(x2, 2)
        x2 = self.dropout1(x2)
        x2 = torch.flatten(x2, 1)
        x2 = self.fc1(x2)
        x2 = self.dropout2(x2)
        x2 = self.fc2(x2)

        x = torch.cat([x1, x2], dim=1)
        x = self.fc_all(x)

        output = F.softmax(x, dim=1)
        return output
class TextCNNModel(nn.Module):

    def __init__(self, vocab_size=10000, emb_size=50, filters=128):
        super(TextCNNModel, self).__init__()
        self.filters = filters
        self.embedding = nn.Embedding(num_embeddings=vocab_size,
                                      embedding_dim=emb_size,
                                      padding_idx=0)
        self.conv = nn.Conv2d(in_channels=1,
                              out_channels=filters,
                              kernel_size=(3, emb_size),
                              stride=1)
        self.dropout = nn.Dropout(0.5)
        self.fc1 = nn.Linear(128, 64)
        self.fc2 = nn.Linear(64, 6)

    def forward(self, x):
        x = self.embedding(x)
        x = x.unsqueeze(1)
        x = self.conv(x)
        x = F.relu(x)
        x = F.max_pool2d(x, kernel_size=(x.size()[2], 1))
        x = self.dropout(x)
        x = x.view(-1, self.filters)
        x = self.fc1(x)
        x = self.dropout(x)
        x = F.relu(x)
        x = self.fc2(x)
        output = F.softmax(x, dim=1)
        return output

6. 学習結果

転移学習の結果は以下の通りです。

# 学習

tracker = 0
train_loss = []
val_loss = []
train_accuracy = []
val_accuracy = []


model.train()

for i in range(epochs):
    
    _train_loss = 0.0
    _train_acc = 0.0
    
    for X_text, X_pos, y in train_dataloader:
        
        tracker += 1
        
        optimizer.zero_grad()
        
        output = model.forward(X_text, X_pos)
        loss = criterion(output, y)
        _, preds = torch.max(output, 1)
        
        loss.backward()
        optimizer.step()
        
        _train_loss += loss.item()
        _train_acc += torch.sum(preds==y).item()
    train_loss.append(_train_loss)
    train_epoch_acc = _train_acc / len(train_dataloader.dataset)
    train_accuracy.append(train_epoch_acc)
    
    _val_loss = 0.0
    _val_acc = 0.0
    
    with torch.no_grad():
            
        for X_text, X_pos,  y in val_dataloader:

            output = model.forward(X_text, X_pos)
            loss = criterion(output, y)
            _, preds = torch.max(output, 1)
            _val_loss += loss.item()
            _val_acc += torch.sum(preds==y).item()
        val_loss.append(_val_loss)
        val_epoch_acc = _val_acc /len(val_dataloader.dataset)
        val_accuracy.append(val_epoch_acc)

    model.train()

    print("epoch", i,
          "\ttrain loss", round(_train_loss, 4), "\ttrain accuracy", round(train_epoch_acc, 4),
          "\tval loss", round(_val_loss, 4), "\tval accuracy", round(val_epoch_acc, 4))
epoch 0  train loss 25.3956  train accuracy 0.5451   val loss 65.1692    val accuracy 0.5955
epoch 1     train loss 24.1934  train accuracy 0.6259   val loss 64.4132    val accuracy 0.6067
epoch 2     train loss 23.999   train accuracy 0.6353   val loss 64.016     val accuracy 0.6236
epoch 3     train loss 23.0829  train accuracy 0.688    val loss 62.8315    val accuracy 0.6461
epoch 4     train loss 22.7322  train accuracy 0.7011   val loss 62.4082    val accuracy 0.6629
epoch 5     train loss 22.415   train accuracy 0.718    val loss 62.6237    val accuracy 0.6517
epoch 6     train loss 22.3255  train accuracy 0.7256   val loss 61.8551    val accuracy 0.6742
epoch 7     train loss 22.4764  train accuracy 0.7199   val loss 61.0919    val accuracy 0.6854
epoch 8     train loss 22.4273  train accuracy 0.7274   val loss 61.4489    val accuracy 0.6798
epoch 9     train loss 22.3758  train accuracy 0.7312   val loss 61.378     val accuracy 0.6742
epoch 10    train loss 22.227   train accuracy 0.7274   val loss 61.4148    val accuracy 0.6685
epoch 11    train loss 22.2422  train accuracy 0.7312   val loss 61.0007    val accuracy 0.691
epoch 12    train loss 21.6772  train accuracy 0.7688   val loss 57.9652    val accuracy 0.7416
epoch 13    train loss 20.9931  train accuracy 0.8083   val loss 59.2871    val accuracy 0.7079
epoch 14    train loss 21.0462  train accuracy 0.8008   val loss 57.5888    val accuracy 0.764
epoch 15    train loss 20.7282  train accuracy 0.8233   val loss 57.1339    val accuracy 0.7697
epoch 16    train loss 20.1716  train accuracy 0.8553   val loss 53.9223    val accuracy 0.8483
epoch 17    train loss 18.5937  train accuracy 0.9511   val loss 51.2095    val accuracy 0.9045
epoch 18    train loss 18.3057  train accuracy 0.9699   val loss 51.0726    val accuracy 0.9045
epoch 19    train loss 18.3522  train accuracy 0.968    val loss 50.8161    val accuracy 0.9157
epoch 20    train loss 18.2825  train accuracy 0.9756   val loss 51.461     val accuracy 0.8989
epoch 21    train loss 18.1666  train accuracy 0.9812   val loss 50.8358    val accuracy 0.9045
epoch 22    train loss 18.0271  train accuracy 0.985    val loss 50.7952    val accuracy 0.9157
epoch 23    train loss 18.0518  train accuracy 0.9831   val loss 51.1253    val accuracy 0.8989
epoch 24    train loss 18.1432  train accuracy 0.9793   val loss 50.6701    val accuracy 0.9213
epoch 25    train loss 18.0733  train accuracy 0.9812   val loss 51.279     val accuracy 0.8933
epoch 26    train loss 18.0739  train accuracy 0.9831   val loss 50.7902    val accuracy 0.9101
epoch 27    train loss 18.0383  train accuracy 0.9831   val loss 50.4226    val accuracy 0.9157
epoch 28    train loss 17.9971  train accuracy 0.9868   val loss 50.9058    val accuracy 0.9045
epoch 29    train loss 18.014   train accuracy 0.985    val loss 50.8351    val accuracy 0.9101

テストデータを適用すると、以下の通り93%の精度となりました。 今後、データを蓄積した上で、精度改善を図っていく予定です。

以上、GCPを利用した名刺OCR API機能のご紹介を簡単にさせていただきました。