非力(自分)でしたが、ChatGPTの力を借りてAzureのFormRecognizerに接続し、AI OCRしてみた

本記事の構成
>
背景
対象読者
動作環境
ChatGPTとのやりとり
最終的なコード
おわりに

# はじめに
> ## 背景
仕事がら、中小企業さんから紙でデータをもらうことが多いです。データの例としては、決算書3期分から始まり、試算表（月次推移表）、売上データ、総勘定元帳などです。財務分析のための決算書3期分入力は、20分程度でできることはわかっており、入力しながら気づくこともあるので許容範囲ですが、試算表（月次推移表）や総勘定元帳から、数字を転記するのが非常に時間がかかっており、何とかできないかと常日頃から試行錯誤していました。

色々試してきた中で、Microsoft AzureのForm Recognizerにたどりついていましたが、API的な活用？まではできておらずForm Recognizer スタジオのLayout で、ファイルをアップロードし、認識された結果をコピペで使うレベルでした。Azureのドキュメントが膨大で読み切れない、理解しきれない、動画解説ほしい（早送り前提）、といった状況で、それ以上の有効活用ができていませんでした。そこでダメ元でChatGPTに頼った所、何とか目的を達成することができました。

> ## 対象読者
・Pythonは何となくわかるけど、知らないことはいっぱいあって、必要に応じて調べて何とかしているといった自分と同じようなレベルの方
・AI OCRを、たまに必要とする方で、日本語の認識精度は必要としておらず、とにかく数字だけ拾えればいいという方（Azure Form Recognizerの日本語認識精度は、今回必要としておらず未確認です。）
・PDFでデータをもらえるけど、AcrobatのOCR機能や、excelのPDFデータインポートでも、表形式の認識がうまくいかない方


> ## 動作環境
【Azure側の準備】
事前にAzureで使用するForm Recognizerのサブスクリプション(価格レベルS0)を作成済です。Form Recognizer スタジオで試していた時に、Free(F0)ではファイルサイズ4MBの制限([Form Recognizer サービスのクォータと制限](https://learn.microsoft.com/ja-jp/azure/applied-ai-services/form-recognizer/service-limits?view=form-recog-3.0.0))があり、都度ファイルサイズを小さくするのも面倒だったので、金額の相場感が全くわからず、かなりビビりながらStandard (S0)でリソースを作成しました。

【Python動作環境】
Anaconda navigator2.0.3 , VS Code 1.76.2 , Python 3.8.8


> ## Form Recognizerを使用した感想
これまで使ってきた中では、一度にそれほど多くのページを読ませることもなく（1～10ページ程度）、全く使わない月もあり、これまでかかった費用は申し訳ないくらいに少額（2022/12 71円、2023/2 89円、2023/3 6円）です。枚数的には月5000ページとか読ませないと料金が発生しないと思っていましたが、おそらく1 秒あたりのトランザクション数の制限に引っかかって、料金が発生しているのかと思っています。
![2023-03-29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/353601/be97b294-7b5b-b44c-a4a9-1684ea99eef2.png)
Form Recognizerスタジオの時も、頻繁にAnalizeをクリックしたら追加費用がかかる？警告があがったような気がします。Pythonから使っている場合も警告を見たような気がします。


# ChatGPTとのやりとり
以下の説明では、ChatGPTからの回答と、こちらからの入力（質問）を区別するために以下の記法で書いています。

こちらからの入力(>太字)           :　**>ChatGPTへの入力内容**
ChatGPTからの回答本文(背景グレー)     :　`ChatGPTからの回答内容` 
ChatGPTからの回答本文途中のコードは、ChatGPTからそのまま出力された内容です。

--------------------------------------------------------------------------
**>azureのform recognizerに接続するpythonコードを生成して**

`AzureのForm Recognizerに接続するPythonコードを生成するには、以下の手順を実行します。`

`1.必要なパッケージをインストールする。以下のコマンドを使用します。`
```
pip install azure-ai-formrecognizer azure.identity
```
**「おわりに.6」 にinstall の補足があります。**

`2.Azureのポータルで、Form Recognizerリソースを作成して、キーとエンドポイントを取得します。`
`3.以下のPythonコードを使用して、Form Recognizerサービスに接続します。`

```
from azure.core.credentials import AzureKeyCredential
from azure.ai.formrecognizer import FormRecognizerClient

# キーとエンドポイントを設定する
endpoint = "<your-endpoint>"
credential = AzureKeyCredential("<your-key>")

# Form Recognizerサービスに接続する
form_recognizer_client = FormRecognizerClient(endpoint, credential)

# 以下に処理するファイルや画像などを指定して、サービスを使用する
```
`4.サンプルコードに従って、サービスを使用します。たとえば、以下のコードは、サービスにPDFファイルを送信して、フォームを認識し、結果を取得する方法を示しています。`
```
import os

# ファイルを読み込む
file_path = os.path.abspath("<your-file-path>")
with open(file_path, "rb") as f:
    form = f.read()

# フォームを送信して、結果を取得する
poller = form_recognizer_client.begin_recognize_content(form)
result = poller.result()

# 結果を表示する
for page in result:
    for table in page.tables:
        for cell in table.cells:
            print("Cell text: {}".format(cell.text))
```
`注意：キーとエンドポイントには、自分のアカウントのものを使用してください。また、Pythonのバージョンによって、一部のコードが異なる場合があります。`

**>送信するPDFファイルをGUIで選択できるようにしてください**

`PDFファイルをGUIで選択して、Form Recognizerサービスに送信するPythonコードの例を以下に示します。以下のコードでは、tkinterモジュールを使用して簡単なGUIを作成し、ファイルダイアログを開いてPDFファイルを選択します。`

```
from tkinter import filedialog
from tkinter import *

from azure.core.credentials import AzureKeyCredential
from azure.ai.formrecognizer import FormRecognizerClient

# キーとエンドポイントを設定する
endpoint = "<your-endpoint>"
credential = AzureKeyCredential("<your-key>")
form_recognizer_client = FormRecognizerClient(endpoint, credential)

# GUIを作成する
root = Tk()
root.title("PDFファイル選択")
root.geometry("400x200")

def choose_file():
    # ファイルを選択するダイアログを開く
    file_path = filedialog.askopenfilename(initialdir = "/", title = "PDFファイルを選択してください", filetypes = (("PDFファイル", "*.pdf"), ("すべてのファイル", "*.*")))
    if file_path:
        # ファイルを読み込む
        with open(file_path, "rb") as f:
            form = f.read()

        # フォームを送信して、結果を取得する
        poller = form_recognizer_client.begin_recognize_content(form)
        result = poller.result()

        # 結果を表示する
        for page in result:
            for table in page.tables:
                for cell in table.cells:
                    print("Cell text: {}".format(cell.text))
    
# GUIのボタンを作成する
button = Button(root, text="PDFファイルを選択", command=choose_file)
button.pack(pady=50)

root.mainloop()
```
`このコードを実行すると、PDFファイルを選択するためのGUIが表示されます。ファイルを選択すると、フォームが認識され、結果がコンソールに表示されます。注意：キーとエンドポイントには、自分のアカウントのものを使用してください。また、Pythonのバージョンによって、一部のコードが異なる場合があります。`

**>GUIが閉じないのですが**
`GUIが閉じない原因としては、root.mainloop()が呼び出された後に、root.destroy()が呼び出されていないことが考えられます。mainloop()はGUIループを開始し、イベントループを実行します。このループが終了しない限り、GUIは表示され続けます。destroy()は、GUIを破棄するためのメソッドです。`

`以下のように、choose_file()関数の最後にroot.destroy()を追加して、ファイル選択後にGUIを破棄するように修正できます。`
```
def choose_file():
    # ファイルを選択するダイアログを開く
    file_path = filedialog.askopenfilename(initialdir = "/", title = "PDFファイルを選択してください", filetypes = (("PDFファイル", "*.pdf"), ("すべてのファイル", "*.*")))
    if file_path:
        # ファイルを読み込む
        with open(file_path, "rb") as f:
            form = f.read()

        # フォームを送信して、結果を取得する
        poller = form_recognizer_client.begin_recognize_content(form)
        result = poller.result()

        # 結果を表示する
        for page in result:
            for table in page.tables:
                for cell in table.cells:
                    print("Cell text: {}".format(cell.text))
        
        # GUIを破棄する
        root.destroy()
```
`この修正を行うと、ファイル選択後にGUIが閉じられます。`

**>resultをdataflame形式で取得したいのですが**
`Azure Form Recognizer APIから取得した結果は、JSON形式で返されます。このJSONデータをPandasライブラリを使ってDataFrame形式に変換することができます。`

`以下は、Azure Form Recognizer APIから取得したJSONデータをDataFrame形式に変換する例です。resultにAzure Form Recognizer APIから取得したJSONデータを格納していると仮定します。`
```
import pandas as pd

# データフレームを作成する
df = pd.DataFrame()

# ページごとにデータを処理する
for page in result:
    # テーブルごとにデータを処理する
    for table in page.tables:
        # セルごとにデータを処理する
        for cell in table.cells:
            # セルの位置とテキストをデータフレームに追加する
            df = df.append({'page': page.page_number, 'row': cell.row_index, 'column': cell.column_index, 'text': cell.text}, ignore_index=True)

# データフレームを表示する
print(df)
```
`このコードでは、dfという空のDataFrameを作成し、forループを使用して各ページ、テーブル、セルにアクセスします。dfには、各セルの位置とテキストが追加されます。append()メソッドを使用して、新しい行をデータフレームに追加します。ignore_index=Trueを指定することで、新しい行のインデックスが自動的に割り当てられます。`

`上記の例を実行すると、セルの位置とテキストが含まれるデータフレームが表示されます。必要に応じて、データフレームをCSVファイルやExcelファイルに保存することもできます。`

**>csvファイルに保存するコードは**

`Pandasライブラリのto_csv()メソッドを使用して、DataFrameをCSVファイルに書き込むことができます。以下は、先程の例で作成したDataFrameをresult.csvというファイル名で保存する例です。`
```
import pandas as pd

# データフレームを作成する
df = pd.DataFrame()

# ページごとにデータを処理する
for page in result:
    # テーブルごとにデータを処理する
    for table in page.tables:
        # セルごとにデータを処理する
        for cell in table.cells:
            # セルの位置とテキストをデータフレームに追加する
            df = df.append({'page': page.page_number, 'row': cell.row_index, 'column': cell.column_index, 'text': cell.text}, ignore_index=True)

# CSVファイルに書き込む
df.to_csv('result.csv', index=False)
```
`この例では、df.to_csv('result.csv', index=False)という1行を追加して、dfをresult.csvというファイル名で保存しています。index=Falseを指定することで、DataFrameのインデックスをCSVファイルに保存しないようにしています。インデックスを保存したい場合は、index=Trueに設定します。`

**>Excelファイルで保存するには**
`Pandasライブラリのto_excel()メソッドを使用して、DataFrameをExcelファイルに書き込むことができます。以下は、先程の例で作成したDataFrameをresult.xlsxというファイル名で保存する例です。`
```
import pandas as pd

# データフレームを作成する
df = pd.DataFrame()

# ページごとにデータを処理する
for page in result:
    # テーブルごとにデータを処理する
    for table in page.tables:
        # セルごとにデータを処理する
        for cell in table.cells:
            # セルの位置とテキストをデータフレームに追加する
            df = df.append({'page': page.page_number, 'row': cell.row_index, 'column': cell.column_index, 'text': cell.text}, ignore_index=True)

# Excelファイルに書き込む
df.to_excel('result.xlsx', index=False)
```
`この例では、df.to_excel('result.xlsx', index=False)という1行を追加して、dfをresult.xlsxというファイル名で保存しています。index=Falseを指定することで、DataFrameのインデックスをExcelファイルに保存しないようにしています。インデックスを保存したい場合は、index=Trueに設定します。`

`なお、Excelファイルに書き込むには、openpyxlパッケージが必要です。openpyxlパッケージがインストールされていない場合は、pip install openpyxlコマンドを使用してインストールしてください。`

**>excel保存時、pageごとに別シートにし、columnとrowにもとづいて、textをexcelに配置してください**
`以下は、openpyxlパッケージを使用して、ページごとに別のシートに分割し、rowとcolumnの値に基づいてtextを配置する例です。`
```
import pandas as pd
from openpyxl import Workbook

# データフレームを作成する
df = pd.DataFrame()

# ページごとにデータを処理する
for page in result:
    # テーブルごとにデータを処理する
    for table in page.tables:
        # セルごとにデータを処理する
        for cell in table.cells:
            # セルの位置とテキストをデータフレームに追加する
            df = df.append({'page': page.page_number, 'row': cell.row_index, 'column': cell.column_index, 'text': cell.text}, ignore_index=True)

# ワークブックを作成する
workbook = Workbook()

# ページごとにシートを作成する
for page_number in df['page'].unique():
    sheet_name = f"Page{page_number}"
    sheet = workbook.create_sheet(sheet_name)

    # テーブルごとにセルを書き込む
    for _, row in df[df['page'] == page_number].iterrows():
        sheet.cell(row=row['row'], column=row['column'], value=row['text'])

# Excelファイルに書き込む
workbook.save('result.xlsx')
```
`この例では、まず、dfを作成します。次に、Workbookクラスを使用して、新しいワークブックを作成します。その後、dfのページごとにループを実行し、create_sheet()メソッドを使用して新しいシートを作成します。シート名には、ページ番号を使用しています。`

`各セルに対しては、iterrows()メソッドを使用して、dfを行ごとに処理し、cell()メソッドを使用して、シートにセルを書き込みます。rowとcolumnには、DataFrameの該当する列の値を使用しています。`

`最後に、save()メソッドを使用して、Excelファイルにワークブックを書き込みます。`

**>ValueError: Row or column values must be at least 1　がでます**

`ValueError: Row or column values must be at least 1は、openpyxlパッケージを使用してExcelファイルに書き込む際に、行または列のインデックスが1未満であることを示しています。通常、このエラーは、以下のような原因が考えられます。`

`rowまたはcolumnの値が、0または負の数に設定されている。`
`rowまたはcolumnの値が、正の整数であるが、1未満の場合。`
`解決策としては、以下のようなものが考えられます。`

`rowとcolumnの値を確認して、正しい値が設定されていることを確認してください。`
`rowまたはcolumnの値が0または負の数の場合、正の数に修正してください。`
`rowまたはcolumnの値が正の整数であり、1未満の場合は、rowまたはcolumnの値に1を加えて、1以上の値に修正してください。`
`例えば、以下のようにmax関数を使用して、rowおよびcolumnの値を1以上にすることができます。`

**このまま修正を任せると、さらに面倒になりそうだったので、自力で修正**

```
            # テーブルごとにセルを書き込む
            for _, row in df[df['page'] == page_number].iterrows():
                sheet.cell(row=row['row']+1, column=row['column']+1, value=row['text'])
```

**>cell.textの中に、半角スペース、全角スペースがあったら削除、△は-に置換、全角・半角のカンマは削除してください**

`cellのtextから、半角スペース、全角スペース、全角カンマ、半角カンマ、△を削除し、全角カンマを半角カンマに変換する方法は、以下のようになります。`

```
import re

# 半角スペース、全角スペース、全角カンマ、半角カンマ、△を削除する
text = re.sub('[ 　,△]', '', cell.text)

# 全角カンマを半角カンマに変換する
text = text.replace('，', ',')
```
`このコードでは、正規表現を使用して、文字列から半角スペース、全角スペース、全角カンマ、半角カンマ、△を削除しています。その後、replaceメソッドを使用して、全角カンマを半角カンマに変換しています。`

`これらの処理を組み合わせると、以下のようになります。`

```
import re

# 文字列の前処理
text = cell.text
text = re.sub('[ 　,△]', '', text)
text = text.replace('，', ',')

# セルに値を書き込む
sheet.cell(row=row_index, column=column_index, value=text)
```
`このコードでは、最初にcell.textをtext変数に代入し、前処理を行った後、sheet.cellを使用して、書き込む値を設定しています。`

**>workbook.saveで、ファイル名は指定した文字列に日時を後ろにつけてください**
`workbook.save()でファイルを保存する場合、ファイル名に日時を付けるには、datetimeモジュールを使用して現在時刻を取得し、それをファイル名に付加する方法があります。`

`具体的には、以下のように実装できます。`

```
import datetime

# 現在時刻を取得する
now = datetime.datetime.now()

# ファイル名に日時を付加する
file_name = 'example_' + now.strftime('%Y%m%d%H%M%S') + '.xlsx'

# ファイルを保存する
workbook.save(file_name)
```
`このコードでは、datetimeモジュールのdatetime.now()メソッドを使用して現在時刻を取得し、strftime()メソッドを使用して、日時を表す文字列を生成しています。そして、ファイル名にこの文字列を付加して、ファイルを保存しています。ファイル名の形式は、example_年月日時分秒.xlsxとなります。example_の部分は、任意のファイル名に変更できます。`

**>日時の部分は数字だけにしてください**
`ファイル名から日時の部分の文字列を生成する場合、数字だけにするには、正規表現を使用することができます。`

`具体的には、以下のように実装できます。`

```
import datetime
import re

# 現在時刻を取得する
now = datetime.datetime.now()

# ファイル名に日時を付加する
date_string = now.strftime('%Y%m%d%H%M%S')
date_string_numbers_only = re.sub('[^0-9]', '', date_string)  # 正規表現で数字だけを抽出
file_name = 'example_' + date_string_numbers_only + '.xlsx'

# ファイルを保存する
workbook.save(file_name)
```
`このコードでは、まずstrftime()メソッドで日時を表す文字列を生成し、それを正規表現で数字だけに変換しています。re.sub()関数で、[^0-9]というパターンにマッチする文字列を空文字列に置換することで、数字以外の文字を取り除いています。`

`最終的に、example_と数字だけの日時文字列を結合して、ファイル名を生成し、workbook.save()でファイルを保存しています。`


# 最終的なコード
```python
import sys
from azure.core.credentials import AzureKeyCredential
from azure.ai.formrecognizer import FormRecognizerClient
from tkinter import filedialog
from tkinter import *

import pandas as pd
from openpyxl import Workbook
import re
import datetime
```

```python
# キーとエンドポイントを設定する
endpoint = "https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/"
credential = AzureKeyCredential("yyyyyyyyyyyyyyyyyyyyyyyy")
form_recognizer_client = FormRecognizerClient(endpoint, credential)
```
xxxxx...,yyyyyy　は、Azure上で作成済のリソース(Form Recognizer)からコピペする。


```python
# GUIを作成する
root = Tk()
root.title("PDFファイル選択")
root.geometry("400x200")

def choose_file():
    # ファイルを選択するダイアログを開く
    file_path = filedialog.askopenfilename(initialdir = "/", title = "PDFファイルを選択してください", filetypes = (("PDFファイル", "*.pdf"), ("すべてのファイル", "*.*")))
    if file_path:
        # ファイルを読み込む
        with open(file_path, "rb") as f:
            form = f.read()

        # フォームを送信して、結果を取得する
        poller = form_recognizer_client.begin_recognize_content(form)
        result = poller.result()

        
        pd.options.mode.use_inf_as_na = False  # 数値を数値形式で保存するためのオプション
        # データフレームを作成する
        df = pd.DataFrame()
        
        
        # ページごとにデータを処理する
        for page in result:
            # テーブルごとにデータを処理する
            for table in page.tables:
                # セルごとにデータを処理する
                for cell in table.cells:
                    
                    # 文字列の前処理
                    text = cell.text
                    text = re.sub('[　, ,，,.,、]', '', text)
                    text = re.sub('[△,▲]', '-', text)                    
                                        
                   
                    # セルの位置とテキストをデータフレームに追加する
                    df = df.append({'page': page.page_number, 'row': cell.row_index, 'column': cell.column_index, 'text': text}, ignore_index=True)
                    

        # 現在時刻を取得する
        now = datetime.datetime.now()
        csv_file_name = 'ocr_result_csv' + now.strftime('%Y%m%d%H%M%S') + '.csv'

        # CSVファイルに書き込む
        df.to_csv(csv_file_name, index=False)
        
        # CSVファイルを読み込んでデータフレームを作成する
        df = pd.read_csv(csv_file_name)
        
        # ワークブックを作成する
        workbook = Workbook()

        # ページごとにシートを作成する
        for page_number in df['page'].unique():
            sheet_name = f"Page{page_number}"
            sheet = workbook.create_sheet(sheet_name)

            # テーブルごとにセルを書き込む
            for _, row in df[df['page'] == page_number].iterrows():
                sheet.cell(row=row['row']+1, column=row['column']+1, value=row['text'])


            # 現在時刻を取得する
            #now = datetime.datetime.now()
            
            #デフォルト保存先 C:\Users\user
            # ファイル名に日時を付加する
            file_name = 'ocr_result_' + now.strftime('%Y%m%d%H%M%S') + '.xlsx'

            # ファイルを保存する
            workbook.save(file_name)
        
     # GUIを破棄する
    print("--result--")
    root.destroy()
        
# GUIのボタンを作成する
button = Button(root, text="PDFファイルを選択", command=choose_file)
button.pack(pady=50)

root.mainloop()
```


# おわりに
最終的なコードも、改良の余地があります。

**わかっている問題点**
~~1. VS Code上のjyupiter notebookで、上記コードを実行後、終了しているはずなのに（アプリケーション間でフォーカスは移ります）、saveされたexcelファイルにフォーカスが移らず、ファイルを開けません。vscodeを再起動すれば、excelファイルを開けます。~~　この現象は出なくなりました。環境のせいだったのかもしれません。

2. saveしたexcel内で数値が文字列として認識されていて、データ集計に使うには数字に変換する必要があります。一旦、CSVとして保存したものをExcelに読み込む等試しましたが、今のところうまくいっていません。仕方ないのでExcel上で範囲選択、コピー、貼り付け時に、0を入力したセルとの加算貼り付けで数値化しています。

3. 問題ではなくて仕様だと思っていますが、数字の塊を数字として扱うために、正規表現を用いて、スペースやカンマ、カンマを誤認識した点(.)、これらの全角、半角があったら削除するようになっています。そのため、小数点以下の数字が混じっている場合は正しく認識しません。

4. Form Recognizerの認識精度が高く、今のところあまり問題ではありませんが、末尾が1の数字とその右側の罫線の間隔が狭い場合、罫線側をl(小文字のエル)または1(数字のイチ)と認識した場合がありました。

5. 日本語自体の読取精度は必要としていませんので、列名、行名などの読取精度は無視しています。これらの入力はそれほど手間がかからないし、必要ならスマホで音声入力します。

6. これも仕様(?)ですが、あくまで元データからデータ化されたテーブルみたいな形式にするだけですので、元のデータがきれいなテーブル形式になっていない場合は、集計にもうひと手間かかります。最近はExcel上でテーブルに変換し、Power Queryで処理してます。

7. 実際一番時間がかかったのは、Azure関連のライブラリのインストールです。condaで入らないのでpipで入れています。途中、ライブラリの競合が解消できず、一度、anacondaをアンインストール、再作成する羽目になりました。

```
pip install azure-core
pip install azure-ai-formrecognizer
```

**azure関連ライブラリ（現在の動作環境）**
```
azure-ai-formrecognizer   3.2.0                    pypi_0    pypi
azure-common              1.1.28                   pypi_0    pypi
azure-core                1.26.3                   pypi_0    pypi
azure-identity            1.12.0                   pypi_0    pypi
azure-keyvault-secrets    4.6.0                    pypi_0    pypi
```

8.excelファイルを開くときにxml関連のエラーメッセージがあがります。
```
<repairedPart>修復されたパーツ: /xl/worksheets/sheet2.xml パーツに XML エラーがありました。 読み込みエラーが発生しました。場所は、行 1、列 0 です。</repairedPart>
```
おそらくjsonファイルをexcel形式に変換時、列0のままになっている所がありエラーになると思われますが　~~後するときに、jsonファイルのヘッダに何か残っているのかと思われますが~~、支障がないのでとりあえず放置してます。

9.正規表現で-(マイナス)への変換を入れている△や▲ですが、おそらくうまく動きません。というのもForm Recognizerでは、△や▲と認識してくれずA と認識されることが多いようです。Aを-に変換してもいいいのですが、自分の用途では△や▲の出現が限られるのでとりあえず、そのままにしています。

---
##### 変更履歴
2023.03.29 最終的なコードから、未使用の関数save_file()のブロックを削除
2023.04.21 azure関連ライブラリのインストールについて追加