文字の入ったPDFをOCRするな高校校歌

# 前提
## タイトルの通り

昨今AI-OCRとか流行りですよね。
[YomiToku](https://www.mlism.com/)さんのおためし版発表以来、陰ながら見守っております。

## 紙のスキャンはOCRしたらいいと思う
YomiTokuさんの宣伝をしたいわけではないので、「レイアウト解析つきOCRっていいよね」と言及する程度にとどめておきます。
とりあえず画像になった表さえもらえれば、かなりの精度でデータを起こせるようになります。レイアウト解析は改善の余地がまだまだあると思うのですが、表らしき構造物をとにかく表にしてくれるというのはポイントが高いです。
事前に表のある位置をアノテーションする必要とかがないわけですね。

## でも、印刷前のPDFはOCRするな
印刷前のPDFって文字を選択してコピペできますよね。*どうしてコピペしないでOCRするんですか??*
気持ちがわからないではないです。PDFって本質的に「文字を配置した絵」なんですよね。だから、画像に直してからOCRしてしまえばいいのではないかというのはごもっともにも思えるわけです。
しかし、画像になってからOCRした場合、同じだった文字が違う文字として書き起こされる可能性がめちゃくちゃあります。実体験、当たり前にあります。これでは電子データを自動処理する意味がありません。
そんなら表記揺れをAIで直すかって? *GPUの無駄遣いをやめろ。*

# アルゴリズムで解ける問題にすーぐAIを持ち出すのをやめろ
今回、[AMEXのクレジット明細もどき](https://gist.githubusercontent.com/nananek/9ca09288aec1848de38f52189dd40b04/raw/aa747bb01c9e1b717f25d76049e734a5a9526a63/amex.pdf)を用意してみました。
実を言うとこれはExcelからエクスポートしているので、`pd.DataFrame`を抜き出す方法が普通にあると思います。試してないけど。しかし、本物のAMEXのPDFはそうではないので、とにかくPDFの上に散らばった文字を組織化する方向で考えていきたいわけです。

すーぐAIに頼る前に、アルゴリズムくらい自分の頭で考えてみたらどうなんですかって思うんですね。文字が座標を持って散らばっているので、どのように分割統治するかという問題として整理できるはずです。

1. 紙面を横に貫く罫線を見つけて水平線で領域分割する
1. 列の塊を見つけて垂直線で領域分割する
1. 列内の改行を見つけて領域分割する
1. 分割された領域内に存在する文字を左から順に取り出して文字列として結合する

これだけの話なんですね。`cv2` とか `numpy` の使い方がわからなかったら随時GPT 4oにでも訊いたらいいと思います。

## PDFの読み込み
```
import pdfplumber
import cv2
import numpy as np
from IPython.display import display # Jupyter Notebook向け

pdf_path = "amex.pdf"

with pdfplumber.open(pdf_path) as pdf:
    for page in pdf.pages:
        pil_image = page.to_image(resolution=dpi).original
        np_image = np.array(pil_image.convert('L'))
        _, np_image = cv2.threshold(np_image, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
        np_image = cv2.bitwise_not(np_image)
        display(Image.fromarray(np_image))
```

ここまでやると各ページのネガポジ反転した画層が出るのではないでしょうか。
なぜネガポジ反転するかというと、`cv2` や `numpy` の作法として、背景が黒い (= 明るさが `0`) のほうがAPIにはめやすいからです。
あと背景が黒い方が夜な夜なコードを書くのに優しい (本業に必要なコードであることは間違いないのに業務時間内に書くことができないため) 。

## 文字のある位置を視覚化してみる
```
    np_words = np.zeros((np_image.shape[0], np_image.shape[1]), dtype=np.uint8)
    scale = np_image.shape[1] / page.width
    char_list = []
    for char in page.chars:
        x0, x1 = char["x0"], char["x1"]
        top, bottom = char["top"], char["bottom"]
        
        # 文字の中心座標を計算
        x_center = (x0 + x1) / 2
        y_center = (top + bottom) / 2

        # 拡大・縮小後の幅と高さを計算
        width = (x1 - x0) * 1.7
        height = (bottom - top) * .9

        # 中心を基準に新しい座標を計算
        x0_adj = x_center - width / 2
        x1_adj = x_center + width / 2
        top_adj = y_center - height / 2
        bottom_adj = y_center + height / 2

        # ピクセル座標に変換
        x0_px = int(x0_adj * scale)
        x1_px = int(x1_adj * scale)
        top_px = int(top_adj * scale)
        bottom_px = int(bottom_adj * scale)

        # 白い矩形 (255: 白)
        cv2.rectangle(np_words, (x0_px, top_px), (x1_px, bottom_px), 255, -1)
        char_list.append((char["text"], x_center * scale, y_center * scale))
    char_list = sorted(char_list, key=lambda x: x[1])
    display(Image.fromarray(np_words))
```

読み込んだ画像とは別に、同じサイズの真っ黒な画像を用意します。
`page` は `page.chars` というリストを持っています。ここには、文字とその存在する矩形領域の座標が入っているわけです。
そこで、文字の存在する領域を白く塗っていくわけですね。

`page.width` と `np_image.shape[1]` は比例の関係にあるので、変換をお忘れなく。
`char_list` はあとで文字を取り出すときに必要になるのでここで作っておきます。

## 水平線を検知してみる
```
    np_image_col = np_image[:, 0]
    for i in range(np_image.shape[0]):
        if np.count_nonzero(np_image[i]) > np_image.shape[1] * .7:
            np_image_col[i] = 255
        else:
            np_image_col[i] = 0
    border_size = 3
    for i in range(border_size, np_image.shape[0]):
        if np.count_nonzero(np_image_col[(i - border_size):i]) > 0:
            np_image_col[i] = 0
    borders, = np.nonzero(np_image_col)
```

おそらく水平方向に紙の横幅の7割も白いピクセルが並んでいたら、そこは罫線に違いないわけです。ところで、罫線は太さを何ピクセルか持っている可能性があるので、そこを考慮して補正する必要があります。

こんな感じで、次々と「罫線で区切られた行」「行内の空白で区切られた列」「列内の空白で区切られたテキスト行」「テキスト行内に存在する文字」という順番で構造化していくわけです。逐一全部解説するのは面倒なので、答えだけ知りたい方は[コード](https://gist.githubusercontent.com/nananek/9ca09288aec1848de38f52189dd40b04/raw/aa747bb01c9e1b717f25d76049e734a5a9526a63/amex-pdf-parser.py)を見てください。

## 自分で手を動かしてみたらどうですか?

コードは置いておきましたので、使えればいいということであれば、お好きに使っていただいて結構なんですが、さっきQiitaに登録したどこの馬の骨かわからない人間の書いたコード、使います?

やることはわかりきっていて、自分で書くのがただただ面倒なのでGPTに全部書かせようかと思ったわけですが、GPTが書くとスパゲッティスクリプトを吐いてくるので、イライラして結局最初から再構築しました。
それに生成AIがウイルス作ってこない保証もないわけですからね。

AIの使い所はよく考えましょうね。