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

未公開の資料を持っている機関が、外部の研究者に OCR をかけてもらいたい。しかし画像そのものは渡したくない

この要求に対する技術的な答えの一つが Compute-to-Data(C2D) です。データを送るのではなく、処理の方をデータのところへ送り、結果だけを返す

本記事は、NDL古典籍OCR-Lite を C2D のアルゴリズムとして登録し、古典籍のページ画像を外に出さないまま翻刻テキストと TEI を受け取るまでの記録です。2026-08-10 に実際に動かした値だけを載せます。

最後に、この方式が古典籍 OCR に対しては額面通りには効かないという話もします。そこが一番大事な部分だと思っています。

実演動画: 以下の流れを、実際のブラウザ操作でそのまま録画したものです。画面も出力もすべて実機で、作り込んだ見た目は混ぜていません。

英語UIをベースにした版もあります(音声・字幕とも英語): Running OCR on a pre-modern Japanese book without handing over the image

動いたもの

入力  : 古典籍のページ画像(NDLデジタルコレクション、188 KB)
処理  : NDL古典籍OCR-Lite(くずし字OCR、CC BY 4.0)
環境  : CPU 1 / RAM 1GB を割当(環境の上限は 2/2GB)/ ネットワーク遮断
時間  : 1 ページ 10.9 秒
出力  : 翻刻テキスト / NDL 形式 XML / TEI / 座標つき JSON

受け取った要約です。

{
  "engine": "NDL古典籍OCR-Lite",
  "license": "CC BY 4.0",
  "totalLines": 9,
  "totalCharacters": 34
}

画像ファイルは一度も利用者に渡っていません。

古典籍のページ画像を登録した資産の詳細画面。左に資料の説明とライセンス、右に価格(無料)と計算環境の選択欄が並ぶ。画面は日本語で表示されている。

登録した古典籍データセットの画面。ポータルは Clio-X(InterPARES Trust AI 系のアーカイブ向けデータスペース実装)を、自前の Ocean Node に繋いで使っています。

なぜ Lite なのか

最初は本家の NDL古典籍OCR を検討しましたが、載りませんでした。

本家 ver.3Lite
ベースイメージCUDA 12.1 + cuDNN不要(CPU)
推論エンジンPyTorch + mmcvonnxruntime のみ
モデル450 MiB + 970 MiB78.8 MiB
実イメージ約 13 GB / amd64 のみ770 MB(圧縮 209 MB)/ arm64 可

決め手はモデル重みがリポジトリに同梱されていることでした。

src/model/rtmdet-s-1280x1280.onnx        38.3 MiB
src/model/parseq-ndl-32x384-tiny-10.onnx 40.5 MiB

C2D のコンテナは enableNetwork: false(外部通信なし)で走ります。実行時にモデルを取りに行く設計だと、この時点で不可能です。同梱されているという一点が、この案件の成否を決めました

つまずいた 3 点

動くまでに 3 回イメージを弾かれました。どれも「知っていれば 5 分、知らないと 1 時間」の類です。

1. WORKDIR/ でなければならない

ノードはアルゴリズムを /data/transformations/algorithm に置き、python $ALGO の形で実行します。この $ALGO相対パスdata/transformations/algorithm)に展開されるため、WORKDIR/ 以外だと解決できません

本家の Dockerfile は WORKDIR /root/kotenocr_cli なので、この点でも不適合でした。

2. 配布物ではなくソースを使う

リリースの _linux.tar.gzx86_64 の GUI アプリで、C2D には使えません。しかしそもそも配布物は不要でした。src/ をソースから使えばよく、必要な依存には linux aarch64 の wheel が揃っています。

FROM python:3.11-slim-bookworm
RUN pip install --no-cache-dir onnxruntime==1.23.2 pillow==12.1.1 numpy==2.2.2 ...
COPY src/ /ndlkotenocr/src/
WORKDIR /            # ← 変更しないこと

GUI 用の flet は入れていません。CLI(src/ocr.py)には不要で、aarch64 の wheel 有無に左右されるうえイメージも太るためです。

3. イメージ名は「ホストから見た名前」

ここが一番てこずりました。

ローカルにビルドしただけでは動きません。ノードは checkDockerImage の後に無条件で pullImage() を走らせるので、レジストリに存在しないと失敗します。そこでローカルに registry:2 を立てたのですが、それでも弾かれ続けました。

Cannot find image host.docker.internal:5050/ndlkotenocr-lite@sha256:… for aarch64.

結論から言うと localhost:5050/… にすれば通ります。ただし理由は、私が最初に考えたものとは違いました。

ノードは 2 つの経路でイメージを確認します。

① this.docker.getImage(name).inspect()   ソケット経由 → ホストの Docker デーモンが答える
② getDockerManifest(name)                ノード自身の fetch で HTTP を叩く

①が成功すれば②には進みません。そして名前の解決は経路ごとに違います

名前①ホストのデーモン②ノードの fetch
localhost:5050/…成功ECONNREFUSED(コンテナ自身を指すため)
host.docker.internal:5050/…失敗https を強制され TLS エラー

②で https になるのは、ノードの parseImage名前に localhost を含むときだけ http:// を付ける実装だからです。host.docker.internal は含まないので https:// が付き、平文のレジストリに対して TLS ハンドシェイクを試みて落ちます。

つまり localhost:5050 が通ったのは、①で先に解決できて②に進まなかったからです。「ホストから見た名前にせよ」という手順は正しいのですが、私が最初に書いた「ノード自身が HTTP で取れるかは別問題」は誤りでした。まさにその経路で落ちていました

なお buildx の既定では attestation を含む OCI index(unknown/unknown を含む)ができます。ただしノードの判定は platforms.some(...) なので、linux/arm64 が 1 つでもあれば通るはずで、ここで弾かれたと考えた私の推測は実装と合いません。実際に何で弾かれたかはログが残っておらず、特定できていません。単一 manifest にする指定自体は、余計なものが混ざらないので付けておく価値はあります。

docker buildx build --platform linux/arm64 --provenance=false --sbom=false -t localhost:5050/ndlkotenocr-lite:arm64 --push .

アルゴリズムも「登録するもの」

C2D では、処理の方も資産として publish します。DDO にはこう入ります。

"algorithm": {
  "container": {
    "image": "localhost:5050/ndlkotenocr-lite",
    "tag": "arm64",
    "checksum": "sha256:c54303263f75e60f6de8a0bb90ab9f9ba7c94e59915f18d3010a9ec9d17aa517",
    "entrypoint": "python $ALGO"
  }
}

コード本体は暗号化され、ノードが預かります。どの環境で何を実行するかが固定されるわけです。

そして資料の側は、許可するアルゴリズムを DID で明示します。

"compute": {
  "allowRawAlgorithm": false,
  "publisherTrustedAlgorithms": [{ "did": "did:op:92ac28b2…" }]
}

……と、書いてあります。しかし調べたところ、今回使った経路ではこの指定は一度も読まれていません

ノードの startCompute.js を読むと、許可アルゴリズムの検査(validateAlgoForDataset)は 320 行目、有償経路の中にしかありません。無償実行のハンドラは 478 行目から始まりますが、そこには validateAlgoForDatasetallowRawAlgorithmpublisherTrustedAlgorithms一度も出てきません。今回のジョブはすべて isFree: true です。

仮に有償経路を通っても、今回の DDO では通りません。filesChecksumcontainerSectionChecksum を空文字で publish しているため、ノードが計算する実ハッシュと一致しないからです。

つまり 「何を実行してよいかを資料の側が決める」は、データモデルとしては表現できているが、この経路では強制されていない。宣言であって制御ではありません。

効いているのはコンテナの固定の方です。checksum でイメージを指定しているので、承認したアルゴリズムの中身が黙って差し替わることはありません。ここは実際に機能しています。

塞ぐには、有償経路を通すか、無償ハンドラに同じ検査を足す必要があります。ポータルではなくノード側の変更です。

計算ジョブで実行するアルゴリズムを選ぶ欄に「NDL古典籍OCR-Lite(くずし字OCR / CC BY 4.0)」が1件だけ表示され、無料と付記されている。下に「この計算ジョブは無料で発注できます」と出ている。

アルゴリズムの選択欄。ここに並ぶのは、資料の側が DID で指定したものだけ……という設計ですが、上に書いたとおり、この経路では指定が読まれていません。

ラッパの書き方

OCR 本体は CLI なので、C2D の規約に合わせる薄いラッパを書きます。

INPUT_DIR = "/data/inputs"
OUTPUT_DIR = "/data/outputs"
EXTS = (".jpg", ".jpeg", ".png", ".tif", ".tiff", ".jp2", ".bmp")

ここに落とし穴があります。入力は /data/inputs/<DID>/… ではなく、フラットに /data/inputs/<URLのbasename> に置かれます。 そして OCR 側は拡張子でフィルタするので、拡張子のないファイルは黙って無視されます

配信側で .jpg を付けておく必要がある、ということです。ここを外すと「対象画像 0 件」で静かに終わります。

出てくるもの

outputs/kotenseki-page.txt       翻刻テキスト
outputs/kotenseki-page.xml       NDL 形式
outputs/kotenseki-page_tei.xml   TEI
outputs/kotenseki-page.json      座標つき
outputs/ocr-summary.json         要約

TEI には帰属表示が自動で入ります。

<name ref="https://github.com/ndl-lab/ndlkotenocr-lite">NDL古典籍OCR-Liteアプリケーション</name>

CC BY 4.0 の要件を、出力側が自分で満たす作りになっています。DH の文脈では、テキストだけでなくそのまま研究に使える形式で出てくるのが効きます。

ジョブの詳細画面。「ジョブが完了しました」の見出しの下に、実行結果として「結果 (outputs.tar) 12.5 KB」「アルゴリズムのログ 264 Bytes」などのダウンロードボタンが並んでいる。

受け取れるもの。outputs.tar に上の 5 ファイルが入っています。画像そのものは、この一覧のどこにもありません。

ここからが本題 — 「データを渡さない」は何を保証しているか

ここまでは、動いたという記録です。しかし、この事例は C2D の説明の弱点をそのまま突いています

C2D はよくこう説明されます。「データは動かない。処理の方が来る。出ていくのは結果だけ」。どちらも機構としては正しい。問題は、後半が機密性の保証として聞こえてしまうことです。

機構が保証しているのは「入力ファイルが転送されないこと」だけです。出力からどれだけ元データを復元できるかは、一切保証していません。それはアルゴリズムが何を出すように書かれているかで決まります。

今回の 2 つの処理が、ちょうど正反対の例になっています。

出力復元可能性
アノテーション件数を数える件数と、資料の label 1 つ要約。ほぼ復元できない
古典籍 OCR翻刻テキスト + TEI + 行ごとの座標写本にとって、テキストこそが中身

正確な翻刻と行ごとの座標を受け取った人は、研究上のほとんどの目的において、その資料を渡されたのと同じになります。

ただし正直に書くと、今回の出力は正確ではありません

に / Dionter / 大正 / 1.3.3 / 二. / 同同一 / 天和二歳 / 同一 / 歳且発句牒板不廉

正しくは『歳発句牒』で、「旦」が「且」に誤読されています。1.3.3 は写り込んだ定規の目盛り、大正 は蔵書印でしょう。表紙の見開きを 1024px に縮小して投げたので、当然ではあります。

つまり 今回の実行そのものは「資料を渡した」ことの証拠にはなっていません。言えるのは「精度が出た場合には、画像を渡さなくても資料を渡したのと同じになる」という条件つきの話です。原理としては成り立ちますが、実演としては未達です。

だから、言い方はこう区別すべきだと思っています。

○ 「ファイルは転送されない」    ← 実証済み
× 「中身は保護される」          ← OCR の結果が反証している

Five Safes の枠組みで正直に並べると、こうなります。

今回
Safe projects未達。許可アルゴリズムの検査が無償経路で走らない
Safe people部分的。トークンの有無で 403 と 200 は分かれるが、今回は publish も実行も同一アドレス
Safe settings部分的。ネットワーク遮断とイメージ固定は効いているが、全部が 1 台の上
Safe data未達。資料そのものに手を加えていない
Safe outputs未達。出力を検査する工程が無い

5 つのうち、通ったと言えるものは 1 つもありません。 最初は「4 つ中 3 つは届いている」と書きかけたのですが、実装を読んだら違いました。

成熟した安全なデータ利用の仕組みでは、出力の検査は訓練された人間の仕事として運用されています(補助ツールはありますが、最終判断は人が持ちます)。C2D はそこを自動化してくれません。

何が要るか

「データを渡さない」を額面通りに成立させるには、何を出力させてよいかの設計が別に要ります。

  • 出力をいったん保留し、提供側が確認してから渡す工程
  • 出力の量や粒度に対する制約(要約は可、全文は不可、など)
  • 現実的な処理(固有表現抽出・索引・埋め込み)は両極の中間にあり、個別に判断が要る

今回はそこに手を付けていません。技術的には通ったが、統治としては半分というのが正直な現状です。

確認できていないこと

  • OCR の精度は評価していません。 測ったのは 10.9 秒という所要時間だけです(1024px に縮小した画像なので、原寸なら結果は変わるはずです)
  • データ保持者とノード運用者が同じ 1 台です。「第三者に預けても安全」は示せていません。TEE には触れていません
  • 複数ページ・大きな画像での挙動。1 ページ、しかも表紙の見開きでしか試していません(本文ページではありません)
  • 同じデータに繰り返しジョブを投げた場合。要約を何度も取れば元を絞り込める(差分攻撃)という論点に触れていません
  • amd64 環境での再現。arm64 でしか動かしていません

技術的な結論としては、NDL古典籍OCR-Lite は C2D に無理なく載ります。モデルが小さく、CPU で動き、重みが同梱されている。この 3 つが揃っている OCR は貴重です。

一方で、載せてみて初めて「では何を出力させるのか」という問いが具体的になりました。動かす前は気づかなかった問題で、これが一番の収穫だったと思っています。