本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

やったこと

くずし字・古文書の画像処理をこれまで Python(Gradio の Hugging Face Space)で動かしていましたが、これを Python 非依存の JS/Nodeonnxruntime-node)に移植してみた記録です。具体的には次の4本立てです。

  1. YOLOv11x の文字検出を ONNX 化して Next.js(API Route)でサーバ推論
  2. **NDL古典籍OCR-Lite の行検出(RTMDet)**を JS に移植
  3. int8 量子化して fp32 と精度・サイズ・速度を比較
  4. NDL OCR のコアを フレームワーク非依存ライブラリに切り出し

また、デプロイ先として Vercel(serverless)で動かすのは難しいことが分かったので、その理由も整理します(コンテナや Node サーバなら動きます)。

使ったモデルは nakamura196/yolov11x-codh-char(MIT、文字 bbox 検出)と、NDL古典籍OCR-Lite(CC BY 4.0、rtmdet-s の行検出+parseq の文字認識)です。

1. YOLOv11 → ONNX 変換

Ultralytics には ONNX export が組み込まれているので、変換自体は一行です。

from ultralytics import YOLO
m = YOLO("best.pt")
m.export(format="onnx", imgsz=1280, opset=12,
         dynamic=True,   # 入力 H/W を可変に(baseline=1280, SAHIタイル=1024 両対応)
         simplify=True,
         nms=False)       # 後処理(NMS)は JS 側で実装するので付けない

確認できた仕様は次のとおりです。

  • 入力 images [batch, 3, H, W] float32(RGB, /255, letterbox)
  • 出力 output0 [1, 5, N] = (cx, cy, w, h, score) × N アンカー(1クラス。imgsz=1280 で N=33600)
  • 精度劣化なし.pt.onnx で検出ボックス数が一致(サンプル画像で 251 個)

注意点として、fp32 で書き出すと 元の .pt(109MB)の約2倍=217MB になります。これが後でデプロイのネックになります。

2. Next.js(onnxruntime-node)でサーバ推論

「UI は Next.js、推論はサーバの Node プロセス」という構成にしました。前処理(letterbox)・後処理(NMS)・SAHI(タイル分割)・読み順グルーピングはすべて TypeScript で実装し直します。

前処理:letterbox

ONNX の入力テンソルを作る部分。ブラウザでもサーバでも canvas で書けます。

const scale = imgsz / Math.max(W, H);
const newW = Math.round(W * scale), newH = Math.round(H * scale);
const padLeft = Math.floor((imgsz - newW) / 2);
const padTop = Math.floor((imgsz - newH) / 2);

const ctx = createCanvas(imgsz, imgsz).getContext("2d");
ctx.fillStyle = "rgb(114,114,114)";  // ultralytics の灰色パディング
ctx.fillRect(0, 0, imgsz, imgsz);
ctx.drawImage(src, padLeft, padTop, newW, newH);

const { data } = ctx.getImageData(0, 0, imgsz, imgsz); // RGBA
const plane = imgsz * imgsz;
const tensor = new Float32Array(3 * plane);
for (let i = 0; i < plane; i++) {
  tensor[i]             = data[i * 4]     / 255; // R
  tensor[plane + i]     = data[i * 4 + 1] / 255; // G
  tensor[2 * plane + i] = data[i * 4 + 2] / 255; // B
}

後処理は出力 [1,5,N] を走査し、score >= conf で絞って xywh→xyxy、letterbox を解除して元座標へ戻し、クラス非依存 NMS をかけるだけです。Python の挙動とほぼ一致しました(canvas のリサイズ補間が PIL と微妙に違うため 251→249 と数個ずれる程度)。

SAHI(タイル分割)

高解像度(縮小率 = 長辺/1280 が 2.0 超)では、画像をそのまま縮小すると小さな文字が潰れます。そこで 1024px のタイルに 20% オーバーラップで分割し、原寸近くで推論 → 元座標に戻してグローバル NMS、という SAHI 風の処理を実装しました。タイルは最大 1024 なので 推論サイズも 1024にすると、1280 へ無駄に拡大せずに済みます(6144×4096 の画像で 84 秒 → 58 秒に短縮)。

ハマりどころ:sharp が Node 25 で入らない

最初は画像処理に定番の sharp を使うつもりでしたが、Node 25.6 で npm install すると prebuilt を拾えずソースビルドに falし、失敗 → インストール全体がロールバックしました。これは npm が sharp の optional な platform 依存(@img/*)を入れない既知のバグで、sharp 自体が Node 25 非対応というわけではありません(後述のライブラリ側では同じ Node 25 で sharp が動いています)。

この Next.js 側では、画像処理を @napi-rs/canvas に切り替えて回避しました。N-API の prebuilt が堅牢で、しかも canvas はブラウザ標準と同じ API なので、前処理コードがほぼそのまま使えます。

実測(CPU):baseline は 2.3 秒、SAHI は 58 秒でした。

3. NDL古典籍OCR-Lite の行検出(RTMDet)を移植

文字 bbox とは別に、NDL の RTMDet による行/レイアウト検出も JS に移植しました。これがあると「行 bbox の表示」や「行領域の外に出た誤検出(定規・色見本など)の除去」ができます。

元の Python 実装を読み解いた要点:

  • ファイル名は rtmdet-s-1280x1280.onnx ですが、実体の入力は 1024×1024(コードはモデルから読むので動いていた)
  • 出力は dets [1,N,5]=(x1,y1,x2,y2,score)labels [1,N]NMS はモデル内蔵(後処理はスコア閾値だけ)
  • 前処理は YOLO と違い、正方形に パディング(左上寄せ)→ 1024 へ resize → RGB→BGR(px - mean) / std(mean/std は BGR 順)→ CHW
const MEAN = [103.53, 116.28, 123.675]; // BGR
const STD  = [57.375, 57.12, 58.395];
// CHW, チャネル順は BGR
tensor[i]             = (b - MEAN[0]) / STD[0];
tensor[plane + i]     = (g - MEAN[1]) / STD[1];
tensor[2 * plane + i] = (r - MEAN[2]) / STD[2];

後処理でボックスを ÷1024 ×(正方パディングサイズ)で元座標に戻し、各ボックスを縦に 2% 拡張します。Python の参照実装と照合したところ、行数 16〜17 本・座標は約 2px 差で一致しました。前処理(BGR・mean/std・パディング・スケール戻し)が正しく移植できている証拠です。

4. int8 量子化と精度比較

217MB は大きいので int8 量子化を試し、fp32 と比較しました。結論から言うと quantize_dynamic + QUInt8 を使うべきで、それ以外はうまくいきませんでした。

方式weight_type結果
quantize_dynamicQUInt8✅ 採用
quantize_dynamicQInt8❌ ロード不能(ConvInteger(opset10) が ORT 1.23 CPU で NOT_IMPLEMENTED
quantize_static(per-channel)QInt8❌ 検出 0 件で壊滅 / QDQ は invalid graph

QUInt8 dynamic での比較(fp32 を正解、IoU≥0.5、CPU、各画像5回の中央値):

画像fp32 boxint8 boxF1recallmIoUt_fp32t_int8
竹取物語(版本)2512500.9900.9880.9592.35s0.99s
伊藤博文書簡(書状)5465360.9690.9600.9642.95s1.15s
東寺百合文書(古文書3192520.8620.7710.9632.47s1.79s

全体では サイズ 228→58MB(3.9倍圧縮)、CPU 推論 2.15 倍高速、F1 0.945、conf 誤差 0.048

注目すべきは、古文書だけ recall が 0.771 に落ちる点です(319→252、低信頼度の文字を取りこぼす)。マッチしたボックスの IoU は 0.96 と高いので「位置ズレ」ではなく「拾い漏れ」です。版本や近代の書状では int8 でほぼ問題ありませんが、くずし字の古文書で再現率が効く用途では fp32 を残すのが無難、という実用的な結論になりました。

5. デプロイ:Vercel(serverless)で動かすのは難しい

デプロイ先として Vercel に載せようとしたところ、serverless の制約に引っかかることが分かりました。

Vercel の Serverless Function には 250MB(unzipped)のサイズ上限があり、

  • best.onnx(fp32)だけで 217MB → モデル単体で上限にほぼ到達
  • onnxruntime-node はパッケージに全 OS 分のバイナリを同梱していて 254MB(linux ネイティブだけなら ~36MB だが、ファイルトレースが落としきれず超過しがち)
  • さらに実行時間:SAHI は 58 秒で、Vercel の maxDuration(Hobby 最大 60 秒 / Pro 300 秒)にぶつかる
  • ステートレスなのでコールドスタートのたびに数十〜200MB のモデルをロード

…と、Vercel の serverless としては実用になりません。int8(58MB)にしてもサイズが上限ギリギリで脆く、SAHI の実行時間問題も残ります。

一方で、next build && next startコンテナや普通の Node サーバ(Fly.io / Render / Cloud Run / VPS など)で動かせば、217MB のモデルも onnxruntime-node も問題なく動きます。難しいのはあくまで Vercel の serverless 環境で動かすことで、ホスティングを選べば回避できる、という整理になりました。

WASM(onnxruntime-web)はどうか

ブラウザ完結も検討しました。コードは軽いです——モデルはすでに ONNX で、前処理・後処理の TS コードはそのまま流用でき、canvas はブラウザ標準。onnxruntime-web の API も onnxruntime-node とほぼ同じ。

大変なのは速度とサイズです。WASM CPU だと YOLO11x は重く、baseline 1 回でも数秒〜十数秒、SAHI(数十タイル)は数分かかり実質不可。WebGPU バックエンドなら baseline は 1〜3 秒まで来て実用圏ですが、対応ブラウザ依存。さらにマルチスレッドには COOP/COEP(cross-origin isolation)が要ります。モデルも int8 で 58MB を初回ダウンロードします。「baseline だけブラウザ完結デモ」なら現実的、というのが落としどころでした。

6. コアをライブラリに切り出す

実は NDL古典籍OCR のフル JS 実装(RTMDet 行検出 + PARSeq 文字認識 + XY-cut 読み順 + ALTO/NDL XML 出力)は、別途作っていた Tropy プラグインにすでにありました。上の Next.js 側で RTMDet を二重に書いてしまっていたので、コアを共有ライブラリに切り出すことにしました。

幸いコアは engine.recognize(sharpImage){ blocks, lines, text } というフレームワーク非依存な形に分離されていたので、抽出はスムーズでした。

  • 新パッケージ @nakamura196/ndl-koten-ocr(独立リポジトリ)
  • src/:engine / rtmdet / parseq / ndl-parser / xy-cut / alto / buffer-to-input
  • 設定 yaml は同梱、モデル(.onnx)は gitignore で symlink 運用
  • Tropy プラグイン側は file:../ndl-koten-ocr のローカル連携で依存
  • テスト:ライブラリ 81 件 / Tropy 51 件、いずれも green

再掲:sharp インストールの確実な回避手順

ライブラリ側は Tropy に合わせて sharp を使います。例の npm optional 依存バグが出たときは、次の順で確実に入りました。

npm install --ignore-scripts
npm install --no-save --ignore-scripts @img/sharp-darwin-arm64@0.34.5 @img/sharp-libvips-darwin-arm64@1.2.4

--ignore-scripts で sharp のビルド失敗によるロールバックを防ぎ、prebuilt(@img/*)を明示追加する、という流れです(バージョンは sharp 本体に合わせる)。

まとめ

  • YOLOv11 → ONNX は一行で、精度劣化もなし
  • JS への移植は前処理/後処理(letterbox・NMS・SAHI・読み順・RTMDet)を素直に書き直せばよく、Python 参照と座標レベルで一致できた
  • int8 量子化QUInt8 dynamic 一択。3.9 倍圧縮・2 倍高速だが、古文書では recall が落ちるので本番は用途次第で fp32
  • デプロイVercel(serverless)で動かすのが難しいことが分かった(サイズ・実行時間の制約)。コンテナや Node サーバなら動く
  • WASM はコードは軽いが速度がネック。baseline は WebGPU で実用圏、SAHI は非現実的
  • 重複実装を避けるため、NDL OCR コアは共有ライブラリ化した

「Python の重い OCR/検出パイプラインを、どこまで JS だけで・どこに置いて動かすか」を一通り試した記録でした。同じくくずし字・古典籍まわりで JS 移植を考えている方の参考になれば幸いです。