YomiTokuをGPUクラウドサービスModalで実行する

[YomiToku](https://github.com/kotaro-kinoshita/yomitoku) は日本語に特化して学習したAI OCRです。Pythonパッケージとして公開されており、インストールすればCLIで簡単にOCR解析が可能です。APIもありますので、Pythonからの操作も可能です。

今回は、GPUクラウドサービスのModal上でYomiTokuを利用する手順を紹介します。手元にGPUがなくとも、Modalを使うことで高度なAI OCRを利用できます。

## 注意点

YomiToku自体には `yomitoku` というCLIコマンドが用意されていますので、必ずしもプログラミングが必要な訳ではありません。今回はModalと組み合わせた場合の実装方法として参考にしてください。

## Modalとは

[Modal](https://modal.com/)はGPUクラウドサービスです。ローカルで記述したPythonのコードを、クラウド上で実行できます。 `modal` コマンドを使ってPythonコードを実行するので、まるでローカルで処理しているかのようにGPUを使ったコードを実行できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4009607/af4e6a83-59c7-4fd8-8e29-76f2f6093ab1.png)

### インストール

Modalのインストールは `pip` コマンドで行います。

```bash
pip install modal
```

インストール後、セットアップを行います。

```bash
python -m modal setup
```

このコマンドを実行すると、ブラウザが立ち上がってModalの紐付け処理を行います。アカウント自体はGitHubやGoogleアカウントで作成できます。

### Modalの基本形

最も基本的なModalのコードです。

```python
import sys

import modal

app = modal.App("example-hello-world")

@app.function()
def f(i):
    if i % 2 == 0:
        print("hello", i)
    else:
        print("world", i, file=sys.stderr)

    return i * i

@app.local_entrypoint()
def main():
    # ローカルで実行
    print(f.local(1000))

    # リモートで実行
    print(f.remote(1000))

    # リモート、かつ並列で実行
    total = 0
    for ret in f.map(range(200)):
        total += ret

    print(total)
```

`@app.function()` というデコレータをつけた関数が、Modal上で実行される関数になります。この関数は、Modal上のコンテナで実行されます。`@app.local_entrypoint()` というデコレータをつけた関数が、ローカルで最初に実行される関数になります。そして、関数名の後ろに `.local()` や `.remote()` などをつけることで、ローカルで実行するか、リモートで実行するかを指定できます。

注意点としては、複数の関数があった場合に、ファイルシステムが異なることです。そのため、ファイルの受け渡しなどは、Volumes という仕組みを使って行います。

## YomiTokuをModalで実行する

では、YomiTokuをModalで実行する手順を紹介します。今回はローカルにある `invoices` ディレクトリにあるファイルを解析対象とします。

```python
import modal
import os
cwd = os.getcwd()
app = modal.App("yomitoku-modal")

@app.local_entrypoint()
def main():
    # invoices ディレクトリにあるファイルを取得
    entries = os.listdir(f"{cwd}/invoices")
    for entry in entries:
        # コンテナ内では、 `/invoices` にマウントしています
        path_image = f"/invoices/{entry}"
        yomitoku_function.remote(path_image)
```

YomiTokuや、YomiTokuで利用するOpenCVなどのパッケージをインストールするためのイメージを作成します。Imageについては、[Images \| Modal Docs](https://modal.com/docs/guide/images)を参照してください。

```python
open_cv_image = (
    modal.Image.debian_slim(python_version="3.11")
    .apt_install("python3-opencv")
    .pip_install(
        "yomitoku",
        "opencv-python~=4.10.0",
    )
    .add_local_dir(
        f'{cwd}/invoices',
        remote_path="/invoices",
    )
)
```

さらに、解析結果を保存するボリュームを用意します。

```python
volume = modal.Volume.from_name("yomitoku", create_if_missing=True)
```

そして、このイメージとボリュームを関数のデコレータで指定します。

```python
@app.function(gpu="any", image=open_cv_image, volumes={"/results": volume })
def yomitoku_function(image_path: str):
  # この中に処理を書きます
```

### yomitoku_functionの実装

関数内部は異なるコンテナで実行されますので、必要なライブラリは関数内で読み込みます。

```python
import json
import cv2
import io
import csv
from yomitoku import DocumentAnalyzer
from yomitoku.data.functions import load_pdf
```

次に必要な変数を用意します。

```python
# 出力ファイル名を取得
output_file_name = image_path.split("/")[-1].split(".")[0]
# 出力を作成（CSV用）
output = io.StringIO()
writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL)
# DocumentAnalyzerの初期化
analyzer = DocumentAnalyzer(visualize=True, device="cuda")
# ドキュメントの読み込み
imgs = load_pdf(image_path)
```

`imgs` はページ数に応じて複数返ってきますので、それぞれのページに対して解析を行います。

```python
for i, img in enumerate(imgs):
    # この中で処理をします
```

AI OCRの処理自体は、以下の処理を実行するだけです。

```python
# AI OCRの実行
results, ocr_vis, layout_vis = analyzer(img)
```

そして、結果をHTML/JSON/Markdown/CSV形式で出力します。変換結果が受け取れるので、その内容をファイル出力します。JSONの場合はdictなので、JSON文字列に変換します。また、CSVの場合は配列になっているので、 `csv` を使ってCSV文字列に変換します。

```python
# HTML形式で解析結果をエクスポート
with open(f"/results/{output_file_name}_{i}.html", "w") as f:
    html = results.to_html("", img=img)
    f.write(html)
print(f"HTML形式で解析結果をエクスポート: /results/{output_file_name}_{i}.html")
# JSON形式で解析結果をエクスポート
with open(f"/results/{output_file_name}_{i}.json", "w") as f:
    f.write(json.dumps(results.to_json("", img=img)))
print(f"JSON形式で解析結果をエクスポート: /results/{output_file_name}_{i}.json")
# Markdown形式で解析結果をエクスポート
with open(f"/results/{output_file_name}_{i}.md", "w") as f:
    f.write(results.to_markdown("", img=img))
print(f"Markdown形式で解析結果をエクスポート: /results/{output_file_name}_{i}.md")
# CSV形式で解析結果をエクスポート
with open(f"/results/{output_file_name}_{i}.csv", "w", newline="", encoding="utf-8", errors="ignore") as f:
    writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL)
    elements = results.to_csv("", img=img)
    for element in elements:
        if element["type"] == "table":
            writer.writerows(element["element"])
        else:
            writer.writerow([element["element"]])

        writer.writerow([""])
    f.write(output.getvalue())
print(f"CSV形式で解析結果をエクスポート: /results/{output_file_name}_{i}.csv")
```

さらに解析した結果のレイアウトファイルなども保存します。

```python
# 可視化画像を保存
print(f"OCR結果の可視化画像を保存: /results/{output_file_name}_ocr_{i}.jpg")
cv2.imwrite(f"/results/{output_file_name}_ocr_{i}.jpg", ocr_vis)
print(f"レイアウト解析結果の可視化画像を保存: /results/{output_file_name}_layout_{i}.jpg")
cv2.imwrite(f"/results/{output_file_name}_layout_{i}.jpg", layout_vis)
```

これで処理は完成です。

### 全体のコード

最終的なコードは以下のようになります。

```python
import modal
import os
cwd = os.getcwd()
app = modal.App("yomitoku-modal")

volume = modal.Volume.from_name("yomitoku", create_if_missing=True)

open_cv_image = (
    modal.Image.debian_slim(python_version="3.11")
    .apt_install("python3-opencv")
    .pip_install(
        "yomitoku",
        "opencv-python~=4.10.0",
    )
    .add_local_dir(
        f'{cwd}/invoices',
        remote_path="/invoices",
    )
)

@app.function(gpu="any", image=open_cv_image, volumes={"/results": volume })
def yomitoku_function(image_path: str):
    import json
    import cv2
    import io
    import csv
    from yomitoku import DocumentAnalyzer
    from yomitoku.data.functions import load_pdf
    # 出力ファイル名を取得
    output_file_name = image_path.split("/")[-1].split(".")[0]
    # 出力を作成（CSV用）
    output = io.StringIO()
    writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL)
    # DocumentAnalyzerの初期化
    analyzer = DocumentAnalyzer(visualize=True, device="cuda")
    # ドキュメントの読み込み
    imgs = load_pdf(image_path)
    for i, img in enumerate(imgs):
        # AI OCRの実行
        results, ocr_vis, layout_vis = analyzer(img)
        # HTML形式で解析結果をエクスポート
        with open(f"/results/{output_file_name}_{i}.html", "w") as f:
            html = results.to_html("", img=img)
            f.write(html)
        print(f"HTML形式で解析結果をエクスポート: /results/{output_file_name}_{i}.html")
        # JSON形式で解析結果をエクスポート
        with open(f"/results/{output_file_name}_{i}.json", "w") as f:
            f.write(json.dumps(results.to_json("", img=img)))
        print(f"JSON形式で解析結果をエクスポート: /results/{output_file_name}_{i}.json")
        # Markdown形式で解析結果をエクスポート
        with open(f"/results/{output_file_name}_{i}.md", "w") as f:
            f.write(results.to_markdown("", img=img))
        print(f"Markdown形式で解析結果をエクスポート: /results/{output_file_name}_{i}.md")
        # CSV形式で解析結果をエクスポート
        with open(f"/results/{output_file_name}_{i}.csv", "w", newline="", encoding="utf-8", errors="ignore") as f:
            writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL)
            elements = results.to_csv("", img=img)
            for element in elements:
                if element["type"] == "table":
                    writer.writerows(element["element"])
                else:
                    writer.writerow([element["element"]])

                writer.writerow([""])
            f.write(output.getvalue())
        print(f"CSV形式で解析結果をエクスポート: /results/{output_file_name}_{i}.csv")
        # 可視化画像を保存
        print(f"OCR結果の可視化画像を保存: /results/{output_file_name}_ocr_{i}.jpg")
        cv2.imwrite(f"/results/{output_file_name}_ocr_{i}.jpg", ocr_vis)
        print(f"レイアウト解析結果の可視化画像を保存: /results/{output_file_name}_layout_{i}.jpg")
        cv2.imwrite(f"/results/{output_file_name}_layout_{i}.jpg", layout_vis)
    output.close()
    print("処理が完了しました。")

@app.local_entrypoint()
def main():
    entries = os.listdir(f"{cwd}/invoices")
    for entry in entries:
        PATH_IMGE = f"/invoices/{entry}"
        yomitoku_function.remote(PATH_IMGE)
```

## 実行する

今回のコードを `runner.py` として保存し、 `modal` コマンドで実行します。

```bash
% modal run runner.py
✓ Initialized. View run at https://modal.com/apps/goofmint/main/ap-K1wZJQ9AlDxbsQJySIuhEG
✓ Created objects.
├── 🔨 Created mount /path/to/runner.py
├── 🔨 Created mount /path/to/invoices
└── 🔨 Created function yomitoku_function.
2025-03-12 05:39:10,245 - yomitoku.base - INFO - Initialize TextDetector
2025-03-12 05:39:11,889 - yomitoku.base - INFO - Initialize TextRecognizer
2025-03-12 05:39:13,780 - yomitoku.base - INFO - Initialize LayoutParser
2025-03-12 05:39:15,228 - yomitoku.base - INFO - Initialize TableStructureRecognizer
2025-03-12 05:39:18,431 - yomitoku.base - INFO - TextDetector __call__ elapsed_time: 1.8317437171936035
2025-03-12 05:39:18,731 - yomitoku.base - INFO - LayoutParser __call__ elapsed_time: 2.130420684814453
2025-03-12 05:39:18,892 - yomitoku.base - INFO - TableStructureRecognizer __call__ elapsed_time: 0.16080307960510254
2025-03-12 05:39:22,152 - yomitoku.base - INFO - TextRecognizer __call__ elapsed_time: 3.2309658527374268
HTML形式で解析結果をエクスポート: /results/invoice_0.html
JSON形式で解析結果をエクスポート: /results/invoice_0.json
Markdown形式で解析結果をエクスポート: /results/invoice_0.md
CSV形式で解析結果をエクスポート: /results/invoice_0.csv
OCR結果の可視化画像を保存: /results/invoice_ocr_0.jpg
レイアウト解析結果の可視化画像を保存: /results/invoice_layout_0.jpg
処理が完了しました。
2025-03-12 05:39:22,635 - yomitoku.base - INFO - Initialize TextDetector
2025-03-12 05:39:23,312 - yomitoku.base - INFO - Initialize TextRecognizer
2025-03-12 05:39:24,112 - yomitoku.base - INFO - Initialize LayoutParser
2025-03-12 05:39:24,585 - yomitoku.base - INFO - Initialize TableStructureRecognizer
2025-03-12 05:39:25,345 - yomitoku.base - INFO - LayoutParser __call__ elapsed_time: 0.13031840324401855
2025-03-12 05:39:25,345 - yomitoku.base - INFO - LayoutParser wrapper elapsed_time: 0.13048863410949707
2025-03-12 05:39:25,568 - yomitoku.base - INFO - TextDetector __call__ elapsed_time: 0.3536806106567383
2025-03-12 05:39:25,568 - yomitoku.base - INFO - TextDetector wrapper elapsed_time: 0.3538353443145752
2025-03-12 05:39:25,604 - yomitoku.base - INFO - TableStructureRecognizer __call__ elapsed_time: 0.2582571506500244
2025-03-12 05:39:25,604 - yomitoku.base - INFO - TableStructureRecognizer wrapper elapsed_time: 0.2584211826324463
2025-03-12 05:39:28,731 - yomitoku.base - INFO - TextRecognizer __call__ elapsed_time: 3.09946608543396
2025-03-12 05:39:28,731 - yomitoku.base - INFO - TextRecognizer wrapper elapsed_time: 3.0996358394622803
HTML形式で解析結果をエクスポート: /results/template_02_0.html
JSON形式で解析結果をエクスポート: /results/template_02_0.json
Markdown形式で解析結果をエクスポート: /results/template_02_0.md
CSV形式で解析結果をエクスポート: /results/template_02_0.csv
OCR結果の可視化画像を保存: /results/template_02_ocr_0.jpg
レイアウト解析結果の可視化画像を保存: /results/template_02_layout_0.jpg
処理が完了しました。
Stopping app - local entrypoint completed.
Runner terminated.
```

そして、結果ファイルがModalのストレージに保存されていれば完成です。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4009607/e9b41c17-fca6-4add-9987-238d1443cd0a.jpeg)


## ダウンロードする

結果ファイルをダウンロードするのも `modal` コマンドで行えます。

```bash
% modal volume get yomitoku / results 
⠙ Downloading file(s) to local...
Downloading file(s) to local... 0:00:12 ━━ (12 out of 19 files completed)
  output_ocr_0.jpg ━━━ 0.0% • 0.0/704.6 kB • ? • -:--:--
  output_layout_0.jpg ━━━ 0.0% • 0.0/959.8 kB • ? • -:--:--
  output_0.md ━━━ 0.0% • 0.0/1.5 kB   • ? • -:--:--
  output_0.json ━━━ 0.0% • 0.0/38.0 kB  • ? • -:--:--
  output_0.html ━━━ 0.0% • 0.0/7.3 kB   • ? • -:--:--
  output_0.csv ━━━ 0.0% • 0.0/1.4 kB   • ? • -:--:--
figures/output_0_figure_0.png ━━━ 0.0% • 0.0/18.9 kB  • ? • -:--:--
```

これで、ローカルに結果ファイルがダウンロードされていれば成功です。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4009607/7335c53c-1212-468a-a559-af9b92336faf.png)

今回OCR処理したPDFファイルの内容です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4009607/022a53da-5bf0-4b44-a2e3-5aa536c78554.png)


以下はOCR結果（Markdown）です。日本語が適切に読み取られ、ヘッダーや明細部分も適切に読み取られています

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4009607/05ef1b5d-b2e6-4f5c-a3b2-5e4e95cb6162.png)


## ライセンス

YomiTokuのライセンスは[コモンズ証 \- 表示 \- 非営利 \- 継承 4\.0 国際 \- Creative Commons](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.ja)です。非商用での個人利用、研究目的においては、ご自由に利用できます。商用目的での利用に関しては、商用ライセンスが必要です。

## まとめ

YomiTokuは決して高性能なGPUが必要な訳ではありませんが、CPUでの実行は時間がかかります。macOSのように cuda に対応していない場合も時間がかかります。

そうした時、Modalを使うことで、ローカルから実行するかのようにGPUを使ったAI OCRが実現できます。ぜひAI OCRを使って、業務効率化を図ってみてください。

[kotaro\-kinoshita/yomitoku: Yomitoku is an AI\-powered document image analysis package designed specifically for the Japanese language\.](https://github.com/kotaro-kinoshita/yomitoku?tab=readme-ov-file)