29
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OSSのノーコード・ローコード開発ツール「プリザンター」 Advent Calendar 2025
17日目の記事です!


今回は、YomiToku を FastAPI で WebAPI 化し、Pleasanter のスクリプトから OCR を実行する仕組みを構築していきます!

  • PDF・写真:
    紙の資料やスキャンしたPDFや写真など「画像として保存されているもの」がまだまだ多く存在します。
    これらはそのままだと必要な情報を探すのに時間がかかります。
    特に日本語文書の場合、従来のOCRでは誤認識が多く、結局手作業で修正する必要もありました。
  • 使用するOCR:
    日本語OCRで精度が高いと話題の「YomiToku」を使用します。
    画像解析モデルは、文字位置の検知・文字列認識・レイアウト解析・表の構造認識を学習する事で高精度な推論を可能とされています。
    初回実行時にモデルをダウンロードすれば、その後はローカル環境で完結できるため、外部にデータが送信されることはなく、機密情報を扱う業務でも安心して利用できます。
    ただし、OCR処理は計算量が多いため、CPUでは処理時間が長くなる点には注意が必要です。
    そのためGPU環境が推奨されます。
  • Pleasanterとの組み合わせ:
    OCRで抽出した文字情報を登録すれば、検索可能な情報資産として活用できます。
    例えば:
    • 会議資料をPDFで保存 → OCRで文字化 → Pleasanterに登録して検索可能に
    • 紙の申請書をスキャン → OCRでデータ化 → 集計や分析に活用
    • 写真に写った掲示物や看板 → OCRで文字抽出 → ナレッジ共有に登録
  • 今回の記事:
    この記事では、FastAPIでYomiTokuをWebAPI化し、Pleasanterのスクリプトから呼び出せるようにします。
    これにより、画像やPDFをアップロードするだけで自動的に文字化し、情報の検索・共有・再利用が容易になる仕組みを構築していきます!

yomitokuのライセンス

LICENSE
本リポジトリ内のソースコードおよび本プロジェクトに関連する HuggingFace Hub 上のモデルの重みファイルは、CC BY-NC-SA 4.0 ライセンスの下で提供されています。
非商用での個人利用・研究目的での利用は自由に行っていただけます。

YomiToku © 2024 by Kotaro Kinoshita is licensed under CC BY-NC-SA 4.0.
To view a copy of this license, visit: https://creativecommons.org/licenses/by-nc-sa/4.0/

商用化/非商用の判断は以下のガイドラインに従い、判断いたします。

ライセンスの商用/非商用の判断のためのガイドライン

OCR解析結果のファイルはこのようなものです。
https://laws.e-gov.go.jp/law/322AC0000000049 より
元画像
b511d05a-75e7-47ff-81d4-adaa582ab977.png
レイアウト
b511d05a-75e7-47ff-81d4-adaa582ab977_0_layout.jpg
OCR結果
b511d05a-75e7-47ff-81d4-adaa582ab977_0_ocr.jpg

環境

  • Windows11
  • GPU NVIDIA GeForce RTX 3060 VRAM 12GB
    (推奨GPU >VRAM8G)
  • Pleasanter ver1.4.15.0
  • YomiToku ver0.10.1
  • Python ver3.12.10
    (3.10~3.12が推奨のよう)

GPUの準備

nvcc --version
# ⇒release 12.6, V12.6.85 これに合わせる
# 先にcpu版を消す
pip3 uninstall torch torchvision
# cuda版を入れる
pip3 install torch torchvision --index-url https://download.pytorch.org/whl/cu126

yomitokuをfastAPIで立てる

pythonは3.12を使う
python最新は3.14とかですが上手くいかない部分があります。

FastAPIを使う

  • 適当な名前でフォルダをつくり作業
# 仮想環境
# コマンドは`python`ではなくて`py -3.12`
py -3.12 -m venv .venv
# 有効化
.venv\Scripts\Activate.ps1
# pythoの実行場所を確認
Get-Command python
# ここからはコマンド`python`
python -m pip install --upgrade pip
# fastapiをインストール
pip install "fastapi[standard]"
# インタープリターctr + shft + p
# python select interp
# ⇒venvを選ぶ
# main.pyに必要な処理を入れる
# サーバーの実行
uvicorn main:app --reload
適当なmain.py(クリック)
main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
async def root():
    return {"message": "Hello World"}

image.png

yomitokuを使う

# インストール
pip install yomitoku
# サーバーの起動
uvicorn main:app --reload
# 初回実行時にYomiTokuのモデルがダウンロードされます
# 以降は外部への接続なし

処理内容

  • エンドポイント/analyze/
  • pdf/画像の内容を処理してマークダウンで返します
  • エンドポイント/download/
  • 処理したファイルをzipでダウンロードします
  • run_analyzeがOCR処理
    • DocumentAnalyzer(visualize=True, device="cuda")でgpuかcpuを変更します

    • results.to_html(html_path, img=img, export_figure=False, export_figure_letter=True)の引数について
      export_figure=False:文書画像内の含まれる図や画像を別ファイルで保存しないようにする
      export_figure_letter=True:画像や図に含まれる文字情報も出力する

    • OUTPUT_DIRフォルダへhtml/mdに変換されたファイルと、解析結果の可視化されたファイルが保存されます

コード全体(クリック)
main.py
import io
import uuid
import zipfile
from fastapi import FastAPI, HTTPException, UploadFile, File
from fastapi.responses import JSONResponse, StreamingResponse
from starlette.concurrency import run_in_threadpool
import shutil
import os
import cv2
from yomitoku import DocumentAnalyzer
from yomitoku.data.functions import load_pdf, load_image
from fastapi.middleware.cors import CORSMiddleware


app = FastAPI()
# cors設定
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost"],
    allow_credentials=True,
    allow_methods=["*"], 
    allow_headers=["*"],
)

# gpuかcpuかでdeviceを変更する
analyzer = DocumentAnalyzer(visualize=True, device="cuda")

UPLOAD_DIR = "./uploads"
OUTPUT_DIR = "./outputs"
os.makedirs(UPLOAD_DIR, exist_ok=True)
os.makedirs(OUTPUT_DIR, exist_ok=True)

    
async def run_analyze(file: UploadFile):
    output_files = []
    file_id = str(uuid.uuid4())
    ext = os.path.splitext(file.filename)[1].lower()
    safe_input_name = f"{file_id}{ext}"
    file_path = os.path.join(UPLOAD_DIR, safe_input_name)


    with open(file_path, "wb") as buffer:
        shutil.copyfileobj(file.file, buffer)
        output_files.extend([{"path": file_path, 
                             "zip_name": file.filename }])

    # ファイル拡張子で分岐
    if ext == ".pdf":
        imgs = load_pdf(file_path)
    elif ext in [".jpg", ".jpeg", ".png"]:
        imgs = load_image(file_path)
    else:
        raise HTTPException(status_code=400, detail={
            "error": "対応していないファイル形式です",
            "files": output_files
        })

    md_contents = []
    # 一ページ分づつ処理
    for i, img in enumerate(imgs):
        # 非同期環境で同期関数を安全に実行
        results, ocr_vis, layout_vis = await run_in_threadpool(analyzer, img)

        # ファイルパス
        base = f"{file_id}_{i}"
        html_path = os.path.join(OUTPUT_DIR, f"{base}.html")
        md_path = os.path.join(OUTPUT_DIR, f"{base}.md")
        ocr_img_path = os.path.join(OUTPUT_DIR, f"{base}_ocr.jpg")
        layout_img_path = os.path.join(OUTPUT_DIR, f"{base}_layout.jpg")

        # 結果を出力
        results.to_html(html_path, img=img, export_figure=False, export_figure_letter=True)
        results.to_markdown(md_path, img=img, export_figure=False, export_figure_letter=True)
        # 解析結果を可視化した画像を出力
        cv2.imwrite(ocr_img_path, ocr_vis)
        cv2.imwrite(layout_img_path, layout_vis)

        # ファイルをまとめる
        output_files.extend([
            { "path": html_path, "zip_name": f"{file.filename}_{i}.html" },
            { "path": md_path, "zip_name": f"{file.filename}_{i}.md" },
            { "path": ocr_img_path, "zip_name": f"{file.filename}_ocr_{i}.jpg" },
            { "path": layout_img_path, "zip_name": f"{file.filename}_layout_{i}.jpg" }
        ])

        # mdの内容をまとめる
        with open(md_path, "r", encoding="utf-8") as f:
            md_contents.append(f.read())

    # mdの文字列を返す
    full_md = "\n\n".join(md_contents)
    return {
        "markdown": full_md,
        "files": output_files
    }

async def zip_download(files: list):
    # メモリ上にzipをつくる
    zip_buffer = io.BytesIO()

    with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
        for file in files:
            # zipへ追加
            zip_file.write(file["path"], arcname=file["zip_name"])
            
    # 読み取り位置を先頭に戻す
    zip_buffer.seek(0)

    # StreamingResponseで返す
    return StreamingResponse(
        zip_buffer,
        media_type="application/zip",
        headers={
            "Content-Disposition": "attachment; filename=files.zip"
        }
    )

@app.post("/analyze/")
async def analyze_ocr(file: UploadFile = File(...)):
    result = None
    try:
        result = await run_analyze(file)
        return JSONResponse(content={"markdown": result["markdown"]})
    
    except HTTPException as e:
        # FastAPIのHTTPExceptionはそのままraise
        result = e.detail
        raise e
    
    except Exception as e:
        # その他の例外は500エラーに変換
        raise HTTPException(status_code=500, detail=str(e))
    
    finally:
        # ファイルの削除
        for file in result["files"]:
            try:
                path = file["path"]
                if os.path.exists(path):
                    os.remove(path)
            except Exception as e:
                print(f"削除失敗: {path} ({e})")


@app.post("/download/")
async def download_zip(file: UploadFile = File(...)):
    result = None
    try:
        result = await run_analyze(file)
        return await zip_download(result["files"])
    
    except HTTPException as e:
        # FastAPI の HTTPException はそのまま raise
        result = e.detail
        raise e
    
    except Exception as e:
        # その他の例外は500エラーに変換
        raise HTTPException(status_code=500, detail=str(e))
    
    finally:
        # ファイルの削除
        for file in result["files"]:
            try:
                path = file["path"]
                if os.path.exists(path):
                    os.remove(path)
            except Exception as e:
                print(f"削除失敗: {path} ({e})")

処理速度

vram12ですがかなり十分な速さじゃないでしょうか!?

プリザンターのスクリプト

スクリプトの追加

  • 設定のHTMLからjsファイルを追加します
    挿入位置:body script bottom
<script src="/scripts/extensions/YomiToku/observer.js"></script>
<script src="/scripts/extensions/YomiToku/main.js"></script>
main.jsのコード(クリック)
main.js
// API実行のボタンを追加
function CreateAddButton() {
    const mainCommands = document.getElementById('MainCommands');
    // ボタン生成の共通関数
    const createButton = (text, id, handler) => {
        const btn = document.createElement('button');
        btn.textContent = text;
        btn.id = id;
        btn.addEventListener('click', (event) => GetFile(event, handler));
        mainCommands.appendChild(btn);
    };
    // OCR実行ボタン
    createButton('OCR実行', 'buttonYomiTokuAPI', PostAnalyze);
    // OCRダウンロードボタン
    createButton('OCRダウンロード', 'buttonYomiTokuAPIZip', PostDownload);
}

function GetFile(event, handler) {
    const btn = event.target;
    const input = document.createElement("input");
    input.type = "file";
    // input.accept = ".pdf"; // PDFだけ選べるように制限
    input.onchange = async (event) => {
        const file = event.target.files[0];
        if (file) {
            btn.disabled = true;
            await handler(file);
            btn.disabled = false;
        }
        btn.disabled = false;
    };
    input.click(); // ファイル選択ダイアログを開く
}


// OCRの実行
async function PostAnalyze(file) {
    const url = "http://localhost:8000/analyze/";
    const formData = new FormData();
    formData.append("file", file); // FastAPI 側の UploadFile に対応

    try {
        const response = await fetch(url, {
            method: "POST",
            body: formData
        });

        // エラー時
        if (!response.ok) {
            const errorText = await response.text();
            throw new Error(errorText);
        }

        const data = await response.json();
        console.log(`[md]\n${data.markdown}`);
        $p.set($p.getControl('Body'), `[md]\n${data.markdown}`);
        return data;

    } catch (error) {
        console.error("Error:", error);
        alert(`Error: ${error.message}`);
        return null;
    }
}
// OCR処理したファイルをzipでダウンロード
async function PostDownload(file) {
    const url = "http://localhost:8000/download/";
    const formData = new FormData();
    formData.append("file", file); // FastAPI 側の UploadFile に対応

    try {
        const response = await fetch(url, {
            method: "POST",
            body: formData
        });

        // エラー時
        if (!response.ok) {
            const errorText = await response.text();
            throw new Error(errorText);
        }
        const blob = await response.blob();
        const downloadUrl = window.URL.createObjectURL(blob);
        alert("ZIPをダウンロードします");

        const a = document.createElement("a");
        a.href = downloadUrl;
        a.download = "result.zip";
        a.click();

        window.URL.revokeObjectURL(downloadUrl);
        return "ZIP downloaded";

    } catch (error) {
        console.error("Error:", error);
        alert(`Error: ${error.message}`);
        return null;
    }
}

// 画面ロード時に呼び出すものをここでまとめて定義する
CreateAddButton();
SetMutationObserver();

  • OCR実行ボタン
    先ほどのAPIでOCR処理をしてマークダウン形式でBodyへ$p.set()します
  • OCRダウンロードボタン
    先ほどのAPIでOCR処理をしたファイルをzipでダウンロードします

image.png

observer.jsのコード(クリック)
observer.js
function SetMutationObserver() {
    const target = document.getElementById("Issues_Body.viewer");
    if (!target) return;

    // observeの設定値
    const observeConfig = {
        childList: true,
        attributes: true,
        characterData: true,
        subtree: true
    };

    // "&lt;br&gt;"を"<br>"に置き換える
    const observer = new MutationObserver(function (mutationsList) {
        // mdクラスが無ければ何もしない
        if (!(target.querySelector(".md"))) return;

        for (const mutation of mutationsList) {
            mutation.addedNodes.forEach(node => {
                if (node.nodeType === Node.ELEMENT_NODE) {
                    node.querySelectorAll("*").forEach(element => {
                        if (element.innerHTML?.includes("&lt;br&gt;")) {
                            element.innerHTML = element.innerHTML.replace(/&lt;br&gt;/g, "<br>");
                        }
                    });
                }
            });
        }
    });
    // observeの開始
    observer.observe(target, observeConfig);
}
  • プリザンターの入力欄は<br>&lt;br&gt;にエスケープ処理されて改行されないのでobserverで監視して置き換えます

  • これでyomitoriのマークダウン文字列のまま見た目もよくなります

  • OCR結果のマークダウンそのままでこのように表示されます
    image.png

さらに

  • 今回はボタンクリックでOCR実行としましたが、サーバースクリプト側でプリザンターへ画像登録/アップロード時にOCR処理が入るようにするという使い方もできそうです
  • サーバー側でのOCR処理なので、スマホで撮った写真をプリザンターへアップロードして文字情報を登録という使い方もできます

プリザンターって

プリザンターは、商用利用でも無償で使えるローコードアプリなので、非ITの現場でも業務改善に大きく貢献してくれる存在です!
私自身、職場では(作りこみすぎると保守・運用できなくなるので)基本的な機能しか使っていませんが、もっと色々な使い方を知りたくて、ブログではさまざまな試行錯誤を紹介するようにしています。
少しでもプリザンターを使う人の助けになれば・無償で使っているのでコミュニティの応援になればという気持ちで書いているので、この記事が誰かの改善のヒントになれば嬉しいです!

参考

29
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
29
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?