6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

国会図書館のOCRをWebサービス化してみた - NDLOCR-Lite × Docker × FastAPI

6
Last updated at Posted at 2026-05-16

はじめに

本記事では、国立国会図書館が公開するNDLOCR-LiteをDocker環境で
REST API化し、PDFや画像からテキストを抽出するWebサービスを
構築していきます。

用語

NDLOCR-Liteとは

NDLOCR-Liteは、NDLOCRの軽量版を目指して開発したOCRであり、ノートパソコン等の一般的な家庭用コンピュータやOS環境で、図書や雑誌といった資料のデジタル化画像からテキストデータが作成できるOCRです。
GPU(Graphics Processing Unit。画像描画等の高度な並列計算を処理する装置。)を必要とせず、軽量なOCR処理が可能です。

公式リポジトリ

内部でAIモデルを利用しており、モデルの再学習なども可能なようです。

ONNXとは

ONNX(Open Neural Network Exchange:オニキスまたはオニックス)は、機械学習やディープラーニングのモデルを表現するためのオープンソースの標準フォーマットです。AI開発において、PyTorchやTensorFlowなど様々なフレームワークが存在しますが、ONNXはそれらの間でモデルをやり取りするための「共通言語」としての役割を果たします。

ONNX Runtime

ONNX形式に変換されたモデルを実際に動作(推論)させるためには、実行エンジンが必要です。その代表的なものが、Microsoftが開発したONNX Runtimeです。ONNX Runtimeを使用することで、ONNXモデルを読み込み、最適化し、様々なハードウェアやOS(Windows、Linux、macOS、Android、iOSなど)上で高速に推論を実行することができます。

FastAPI

PythonでWeb APIをすばやく簡単に構築できるモダンなフレームワークです。
ブラウザ上でAPIの動作確認ができるドキュメント(例:Swagger UI)を自動で作成してくれます。

githubリポジトリ

こちらにコード等アップロードしています。
今回はphase01までの内容になります。
今後WebブラウザからD&Dを可能にしたり、

構築

開発環境構築

任意の場所にアプリ開発用のフォルダを用意します。
今回は以下としています。

ndlocr-lite-app/に以下のファイルが存在する
 - docker-compose.phase01.yml

ndlocr-lite-app/ndlocr/に以下のファイルが存在する
  - Dockerfile
  - server.py
  - requirements-server.txt

docker-compose.phase01.yml

# Phase 1: NDLOCR-Lite 単体確認用
# ndlocr コンテナだけ起動して OCR が動くことを確認する
#
# 起動: docker compose -f docker-compose.phase01.yml up --build
# 確認: curl -X POST http://localhost:8080/ocr -F "file=@test.jpg"

services:
  ndlocr:
    build: ./ndlocr
    ports:
      - "8080:8080"
    environment:
      - LOG_LEVEL=debug
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 60s

Dockerfile

# ndlocr/Dockerfile
# NDLOCR-Lite を git clone してFastAPIラッパーをかぶせる
#
# ビルド時に ndlocr-lite をクローンしてモデルファイルをイメージに焼き込む。
# → サーバ起動後にダウンロード不要・再現性が高い

FROM python:3.11-slim

WORKDIR /app

# システム依存(lxml, Pillow, pdf2image用)
RUN apt-get update && apt-get install -y --no-install-recommends \
    git curl libgomp1 libgl1 libglib2.0-0 poppler-utils \
    && rm -rf /var/lib/apt/lists/*

# NDLOCR-Lite をクローン(モデルファイルごと)
# ※ .onnx モデルは src/model/ に同梱されているので追加DL不要
RUN git clone https://github.com/ndl-lab/ndlocr-lite.git /ndlocr-lite \
    && pip install --no-cache-dir -r /ndlocr-lite/requirements.txt

# REST APIラッパーの依存
COPY requirements-server.txt .
RUN pip install --no-cache-dir -r requirements-server.txt

# ラッパーサーバのコード
COPY server.py .

# NDLOCR-Lite の src を Python パスに追加するため環境変数で指定
ENV PYTHONPATH=/ndlocr-lite/src
ENV OCR_SRC=/ndlocr-lite/src

EXPOSE 8080

CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8080"]

requirements-server.txt

fastapi==0.115.0
uvicorn[standard]==0.30.0
python-multipart==0.0.9
Pillow==10.4.0
pdf2image==1.17.0

server.py

注意: NDLOCR-Lite の出力 JSON は contents が二重リスト構造のため、
フラット化処理(_parse_output_dir 内)が必要。
また PDF は PIL で直接開けないため pdf2image + poppler で変換してから OCR にかける。

"""
ndlocr/server.py
NDLOCR-Lite を REST API として公開するラッパー

NDLOCR-Lite の ocr.py は CLI前提の設計なので、
内部の OCR エンジン(DEIMv2 + PARSeq)を直接呼ぶ形でラップする。
"""

import os
import sys
import io
import json
import tempfile
import logging
from pathlib import Path
from typing import Optional

from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.middleware.cors import CORSMiddleware

# NDLOCR-Lite の src を import
OCR_SRC = os.environ.get("OCR_SRC", "/ndlocr-lite/src")
sys.path.insert(0, OCR_SRC)

logging.basicConfig(level=os.environ.get("LOG_LEVEL", "info").upper())
logger = logging.getLogger(__name__)

app = FastAPI(title="NDLOCR-Lite API", version="1.0.0")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])

# ── モデルを起動時に一度だけロード ──────────────────────────────
_ocr_engine = None

def get_engine():
    global _ocr_engine
    if _ocr_engine is None:
        logger.info("Loading NDLOCR-Lite models...")
        try:
            from ocr import OcrEngine  # NDLOCR-Lite v1.x の内部クラス
            _ocr_engine = OcrEngine()
            logger.info("Models loaded.")
        except ImportError:
            logger.warning("OcrEngine not found, falling back to subprocess mode")
            _ocr_engine = "subprocess"
    return _ocr_engine


# ── エンドポイント ──────────────────────────────────────────────

@app.on_event("startup")
async def startup():
    get_engine()


@app.get("/health")
def health():
    engine = get_engine()
    return {
        "status": "ok",
        "engine": "loaded" if engine else "not_loaded",
        "mode": "subprocess" if engine == "subprocess" else "direct",
    }


@app.post("/ocr")
async def ocr(file: UploadFile = File(...)):
    content = await file.read()
    suffix  = Path(file.filename or "upload").suffix.lower()

    if suffix not in {".jpg", ".jpeg", ".png", ".tiff", ".tif", ".bmp", ".jp2", ".pdf"}:
        raise HTTPException(400, f"未対応のファイル形式: {suffix}")

    engine = get_engine()

    # ─ PDF は先にページ画像へ変換してから処理 ─
    if suffix == ".pdf":
        return await _run_pdf(engine, content)

    # ─ Direct モード(OcrEngineを直接使う) ─
    if engine != "subprocess":
        return await _run_direct(engine, content, suffix)

    # ─ Subprocess モード(ocr.py を外部プロセスで叩く) ─
    return await _run_subprocess(content, suffix)


# ── PDF モード ──────────────────────────────────────────────────

async def _run_pdf(engine, content: bytes) -> dict:
    """PDF をページ画像に展開して各ページを OCR し結合する"""
    try:
        from pdf2image import convert_from_bytes
        pages = convert_from_bytes(content)
    except Exception as e:
        raise HTTPException(400, f"PDF 変換エラー: {e}")

    all_regions = []
    for img in pages:
        if engine != "subprocess":
            import asyncio
            loop = asyncio.get_event_loop()
            result = await loop.run_in_executor(None, engine.run, img)
            page = _format_result(result)
        else:
            buf = io.BytesIO()
            img.save(buf, format="PNG")
            page = await _run_subprocess(buf.getvalue(), ".png")
        all_regions.extend(page.get("regions", []))

    full_text = "\n".join(r["text"] for r in all_regions if r.get("text"))
    return {"text": full_text, "layout_hint": _guess_layout_hint(all_regions), "regions": all_regions}


# ── Direct モード ───────────────────────────────────────────────

async def _run_direct(engine, content: bytes, suffix: str) -> dict:
    import asyncio
    from PIL import Image

    try:
        img = Image.open(io.BytesIO(content)).convert("RGB")
    except Exception as e:
        raise HTTPException(400, f"画像読み込みエラー: {e}")

    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(None, engine.run, img)
    return _format_result(result)


# ── Subprocess モード ────────────────────────────────────────────

async def _run_subprocess(content: bytes, suffix: str) -> dict:
    import asyncio

    with tempfile.TemporaryDirectory() as tmpdir:
        in_path  = Path(tmpdir) / f"input{suffix}"
        out_dir  = Path(tmpdir) / "output"
        out_dir.mkdir()
        in_path.write_bytes(content)

        cmd = [
            sys.executable,
            str(Path(OCR_SRC) / "ocr.py"),
            "--sourceimg", str(in_path),
            "--output",    str(out_dir),
        ]

        proc = await asyncio.create_subprocess_exec(
            *cmd,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)

        if proc.returncode != 0:
            logger.error("ocr.py stderr: %s", stderr.decode())
            raise HTTPException(500, f"OCR処理エラー: {stderr.decode()[:200]}")

        return _parse_output_dir(out_dir, in_path.stem)


def _parse_output_dir(out_dir: Path, stem: str) -> dict:
    json_files = list(out_dir.glob(f"{stem}*.json"))
    txt_files  = list(out_dir.glob(f"{stem}*.txt"))

    regions     = []
    layout_hint = ""

    if json_files:
        try:
            data = json.loads(json_files[0].read_text(encoding="utf-8"))
            contents = data.get("contents") or data.get("regions") or []
            # NDLOCR-Lite の contents は [[{...}, ...], [...]] のネスト構造
            if contents and isinstance(contents[0], list):
                contents = [item for sublist in contents for item in sublist]
            for i, item in enumerate(contents):
                text = item.get("text") or item.get("content") or ""
                rtype = item.get("type") or item.get("category") or "本文"
                regions.append({"text": text, "type": rtype, "order": i})
            layout_hint = _guess_layout_hint(regions)
        except Exception as e:
            logger.warning("JSON parse error: %s", e)

    full_text = "\n".join(r["text"] for r in regions)
    if not full_text and txt_files:
        full_text = txt_files[0].read_text(encoding="utf-8")

    return {
        "text":        full_text,
        "layout_hint": layout_hint,
        "regions":     regions,
    }


def _format_result(result) -> dict:
    if isinstance(result, str):
        return {"text": result, "layout_hint": "", "regions": []}
    if isinstance(result, dict):
        return {
            "text":        result.get("text", ""),
            "layout_hint": _guess_layout_hint(result.get("regions", [])),
            "regions":     result.get("regions", []),
        }
    return {"text": str(result), "layout_hint": "", "regions": []}


def _guess_layout_hint(regions: list) -> str:
    types = [r.get("type", "") for r in regions if r.get("type")]
    seen  = list(dict.fromkeys(types))
    return ", ".join(seen)

コンテナイメージのbuild

以下のコマンドを使ってコンテナイメージをbuildします。
✔ ndlocr-lite-app-ndlocr Built と出力されればOKです。
また、docker image ls コマンドでコンテナイメージが出力されることを確認します。

PS C:\Users\ohtsu\Documents\アプリ\ndlocr-lite-app> docker compose -f .\docker-compose.phase01.yml build

中略

#14 resolving provenance for metadata file
#14 DONE 0.0s
[+] Building 1/1
 ✔ ndlocr-lite-app-ndlocr  Built

PS C:\Users\ohtsu\Documents\アプリ\ndlocr-lite-app> docker image ls
REPOSITORY               TAG       IMAGE ID       CREATED         SIZE
ndlocr-lite-app-ndlocr   latest    1e8f24c8773b   2 minutes ago   1.33GB

コンテナのデプロイ

buildしたコンテナイメージを使ってコンテナをデプロイします。

PS C:\Users\ohtsu\Documents\アプリ\ndlocr-lite-app> docker compose -f docker-compose.phase01.yml up -d
[+] Running 2/2
 ✔ Network ndlocr-lite-app_default     Created                                                                       0.0s
 ✔ Container ndlocr-lite-app-ndlocr-1  Started

動作確認

以下のコマンドをPowerShellで実行してリッスンしているかを確認します。
StatusCodeが200で返ってくることを確認します。

curl http://localhost:8080/health

StatusCode        : 200
StatusDescription : OK
Content           : {"status":"ok","engine":"loaded","mode":"subprocess"}

また、Webブラウザで http://localhost:8080/docs にアクセスするとSwagger UIの一覧が出てくればOKです。

APIがリッスンしていることを確認しましたので、実際にファイルを使って確認してみます。
こちらにある【業務委託契約書(ひな形)】のテンプレートをサンプルとしてOCRを試してみます。

以下のコマンドを実行します。

curl -Method POST http://localhost:8080/ocr -Form "file=get_item('C:\パス\サンプル.pdf')"

今回実行した結果は以下です。
pdfの内容を読み取れていそうです。

PS C:\Users\ohtsu\Documents\アプリ\ndlocr-lite-app> curl.exe -X POST http://localhost:8080/ocr -F "file=@C:\Users\ohtsu\Documents\アプリ\ndlocr-lite-app\sample-file\itaku20241015-min.pdf"
{"text":"業務委託契約書\n委託者○○○○(以下「甲」という)と受託者○○○○(以下「乙」という)は、\n○○○業務(以下「委託業務」という。詳細は第2条に定める)の委託にあたり、以\n下のとおり業務委託契約(以下「本契約」という)を締結する。\n第1条(総則)\n1.甲は、本契約に定めるところに従い、委託業務を乙に委託し、乙はこれを受託す\nる。\n2.本契約に定める業務委託は、委任契約とする。\n第2条(委託業務)\n委託業務について、次のとおりとする。\n(1)委託期間\n令和◦年◦月◦日から令和◦年◦月◦日までとする。\n(2)委託業務内容\n〇〇〇〇\n〇〇〇〇\n(3)善管注意義務\n乙は、委託業務に関して、善良なる管理者の注意をもって誠実にこれを遂行す\nるものとする。\n(4)遂行状況の報告\n乙は、甲からの求めに応じて委託業務の遂行状況を都度報告するものとする。\n(5)委託業務の変更\n委託業務を変更する場合は、事前に相手方と協議の上、

中略

{"text":"本契約に定めのない事項及び疑義を生じた事項については、その都度、甲、乙協議の","type":"本文","order":38},{"text":"上これを決定するものとする。","type":"本文","order":39},{"text":"第16条(準拠法)","type":"本文","order":40},{"text":"本契約の準拠法は日本法とする。","type":"本文","order":41},{"text":"第17条(裁判管轄)","type":"本文","order":42},{"text":"本契約に関し、甲乙間の紛争については、〇〇地方裁判所を第一審の専属的合意管轄","type":"本文","order":43},{"text":"裁判所とする。","type":"本文","order":44},{"text":"以上、本契約の証として、正本2通を作成し、甲乙記名捺印のうえ、各1通を保有す","type":"本文","order":0},{"text":"る。","type":"本文","order":1},{"text":"令和○年○月○日","type":"本文","order":2},{"text":"(甲)","type":"本文","order":3}]}

ハマりポイント

PDF が空レスポンスになる

原因①: PIL は標準で PDF を開けない。poppler-utilspdf2image を追加し、PDF をページ画像に変換してから OCR にかける必要がある。

原因②: NDLOCR-Lite の出力 JSON の contents は二重リスト構造になっている。

{
  "contents": [
    [
      { "text": "業務委託契約書", ... },
      { "text": "委託者○○○○...", ... }
    ]
  ]
}

フラット化せずにパースすると item.get("text") が失敗して空になる。_parse_output_dir 内で以下の処理が必要。

if contents and isinstance(contents[0], list):
    contents = [item for sublist in contents for item in sublist]

デプロイ用のubuntu24.04サーバ構築

サーバのHWスペックは以下です。

以下のコマンドを実行します。
Docker及びDocker composeをインストールします。

test@ocr-dev-env:~$ sudo su -
[sudo] password for test:
root@ocr-dev-env:~# apt update && apt upgrade -y

root@ocr-dev-env:~# apt install ca-certificates curl
root@ocr-dev-env:~# install -m 0755 -d /etc/apt/keyrings
root@ocr-dev-env:~# curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
root@ocr-dev-env:~# chmod a+r /etc/apt/keyrings/docker.asc
root@ocr-dev-env:~# tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
root@ocr-dev-env:~# apt update
root@ocr-dev-env:~# apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

root@ocr-dev-env:~# systemctl start docker
root@ocr-dev-env:~# systemctl enable docker

root@ocr-dev-env:~# docker --version
Docker version 29.5.0, build 98f1464
root@ocr-dev-env:~# docker compose version
Docker Compose version v5.1.3

githubリポジトリからpullするためのgitコマンドもインストールします。

root@ocr-dev-env:~# apt install -y git

リポジトリからクローンします。

root@ocr-dev-env:~# git clone https://github.com/ohtsuka-shota/ndlocr-lite-app/
root@ocr-dev-env:~# ls -ltr
total 4
drwxr-xr-x 7 root root 4096 May 16 13:14 ndlocr-lite-app

移動して環境をデプロイします。

root@ocr-dev-env:~# cd ndlocr-lite-app
root@ocr-dev-env:~/ndlocr-lite-app# docker compose -f docker-compose.phase01.yml build
root@ocr-dev-env:~/ndlocr-lite-app# docker compose -f docker-compose.phase01.yml up -d

root@ocr-dev-env:~/ndlocr-lite-app# docker ps
CONTAINER ID   IMAGE                    COMMAND                  CREATED         STATUS                            PORTS                                         NAMES
1890a7618a90   ndlocr-lite-app-ndlocr   "uvicorn server:app …"   4 seconds ago   Up 3 seconds (health: starting)   0.0.0.0:8080->8080/tcp, [::]:8080->8080/tcp   ndlocr-lite-app-ndlocr-1

デプロイしたコンテナに対してAPIでOCRを試してみます。問題なさそうですね。

PS C:\WINDOWS\system32> curl.exe -X POST http://172.18.251.145:8080/ocr -F "file=@C:\Users\otsuka-shota\Documents\pdf_test.pdf"
{"text":"PDFのアップロードテスト用に作成しました\nテスト用PDF\n作成者\n見出し1\n(このテキストのような)プレースホルダーテキストをタップして入力するだけで、すぐに作成を\n開始できます。\n・PC、タブレット、スマートフォンからWordを使ってこの文書を表示、編集できます。\n・テキストの編集が可能で、画像、図形、表などのコンテンツの挿入も簡単です。\nWindows、Mac、 Android、iOSデバイスからWordを使ってクラウドにシームレスに文\n書を保存できます。\n・2ページ目も用意してあります。\n見出し2\nファイルから画像を挿入したり、または図形、テキストボックス、表を追加をしたいとします。そ\nの場合は、リボンの[挿入]タブで、必要なオプションをタップするだけです。\n引用文\nこのページに表示されている文字列の書式は、リボンの[ホーム]タブ上にある「スタイル]か\nら1タップで簡単に設定できます。\n列見出し\n行見出し\n行見出し\nテキスト\nテキスト\n123.45\n123.45","layout_hint":"本文","regions":[{"text":"PDFのアップロードテスト用に作成しました","type":"本文","order":0},{"text":"テスト用PDF","type":"本文","order":1},{"text":"作成者","type":"本文","order":2},{"text":"見出し1","type":"本文","order":3},{"text":"(このテキストのような)プレースホルダーテキストをタップして入力するだけで、すぐに作成を","type":"本文","order":4},{"text":"開始できます。","type":"本文","order":5},{"text":"・PC、タブレット、スマートフォンからWordを使ってこの文書を表示、編集できます。","type":"本文","order":6},{"text":"・テキストの編集が可能で、画像、図形、表などのコンテンツの挿入も簡単です。","type":"本文","order":7},{"text":"Windows、Mac、 Android、iOSデバイスからWordを使ってクラウドにシームレスに文","type":"本文","order":8},{"text":"書を保存できます。","type":"本文","order":9},{"text":"・2ページ目も用意してあります。","type":"本文","order":10},{"text":"見出し2","type":"本文","order":0},{"text":"ファイルから画像を挿入したり、または図形、テキストボックス、表を追加をしたいとします。そ","type":"本文","order":1},{"text":"の場合は、リボンの[挿入]タブで、必要なオプションをタップするだけです。","type":"本文","order":2},{"text":"引用文","type":"本文","order":3},{"text":"このページに表示されている文字列の書式は、リボンの[ホーム]タブ上にある「スタイル]か","type":"本文","order":4},{"text":"ら1タップで簡単に設定できます。","type":"本文","order":5},{"text":"列見出し","type":"本文","order":6},{"text":"行見出し","type":"本文","order":7},{"text":"行見出し","type":"本文","order":8},{"text":"テキスト","type":"本文","order":9},{"text":"テキスト","type":"本文","order":10},{"text":"123.45","type":"本文","order":11},{"text":"123.45","type":"本文","order":12}]}

続き

Reactを使ってWebUIを追加したものです

6
2
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
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?