この記事の結論
onnxruntime-webなら、写真を外に出さずにブラウザだけでAI推論できます。僕の会社ではナンバーを隠すツールをサーバー費・API費0円で作り、Macで1枚0.2〜1.0秒で処理しました。
目次9項目
- onnxruntime-webでブラウザ内推論を組む全体像:エンジン・モデル・ヘッダーの3点
- ナンバープレートのぼかしを端末内で:検出は21/25枚、隠し方は白いプレート
- COOPとCOEPでcrossOriginIsolatedに:共用レンタルサーバーでもマルチスレッドが効いた
- iPhoneの罠1:canvasの上限を避けて、書き出しは長辺約4000pxに
- iPhoneの罠2:共有シートはタップ直後しか開けないので、書き出しを先回りする
- Service Workerでモデルをキャッシュ:2回目から約25MBを取り直さない
- ナンバーを隠すアプリとして使うときの注意:自動は100%ではない
- まとめ
- よくある質問
【無料・メールだけ】Claude Code 2daysセミナー(第二の脳構築講座)の次回開催の案内を受け取る →
onnxruntime-webを使えば、画像のAI推論はブラウザの中だけで終わります。僕がナンバーを隠す小道具で必要だったのは、推論エンジンとモデルを自前のサーバーに置くことと、ヘッダー2行でマルチスレッドにすることでした。写真は利用者の端末から一度も出ません。
2026年9月11日、取引先の車・バイクの店のブログ写真用に、ナンバーを自動で見つけて隠すツールを作りました。翌12日までに10の版を出し、サーバー費とAPI費は0円のままです。処理はMacの実測で1枚0.2〜1.0秒でした。
この記事では、構成と、iPhoneでつまずいた書き出しと保存、2回目から速くするキャッシュまでを順に書きます。「写真をAIで加工したいけれど、お客さんの写真を外のサービスに送るのは避けたい」と迷っている方に向けた記録です。
onnxruntime-webでブラウザ内推論を組む全体像:エンジン・モデル・ヘッダーの3点
onnxruntime-webでブラウザ内推論を組むなら、推論エンジンのファイル、ONNX形式のモデル、ページのヘッダーの3つがそろえば動きます。AI用のサーバーもAPIの鍵も要りません。
ここでいうONNXとは、学習済みのAIモデルを、作った道具に関係なく動かせるようにした共通のファイル形式です。onnxruntime-webは、このファイルをブラウザの中で実行するためのライブラリです。
公式の文書では、onnxruntime-webはCPU向けのWebAssembly(WASM)のほか、WebGPU・WebGL・WebNNでも動きます。WASMはONNXの演算をすべて扱え、ほかは一部だけ対応だと書かれています。
僕の会社のツールは、WASMだけで組みました。どの層に何を使ったかを並べます。
| 層 | 使ったもの | 大きさ・メモ |
|---|---|---|
| 推論 | onnxruntime-web 1.29.0(WASM)を自前で配置 | 3ファイルで約14MB。CDNは使わない |
| モデル | ナンバー検出用のYOLO11n(ONNX) | 10.5MB。推論サイズは1280 |
| 高速化 | COOPとCOEPのヘッダー | crossOriginIsolatedにして最大4スレッド |
| 画像 | createImageBitmapからcanvasへ | 書き出しは長辺約4000px(12.5Mpx)まで |
| 保存 | iPhoneは共有シート、PCはダウンロード | 複数枚のPCはZIPにまとめる |
| 2回目以降 | Service Worker | エンジンとモデル約25MBをキャッシュ |
僕がこの形を選んだのは、置き場所が共用のレンタルサーバーだったからです。共用サーバーではAIの推論を回せません。計算は利用者の端末に任せれば、費用も通信待ちも増えず、写真も外に出ません。
ファイルは自分のサーバーに置き、置き場所は絶対URLで書く
推論エンジンは ort.wasm.min.js と ort-wasm-simd-threaded.wasm・.mjs の3ファイルです。
CDNから読まず、ツールと同じフォルダの vendor/ に置きました。外のサーバーから何も読まない形なので、後で書くヘッダーを安全に付けられます。
置き場所の指定は ort.env.wasm.wasmPaths で行います。ここを相対パスにすると読み込みに失敗し、no available backend found が出ます。
ort.env.wasm.wasmPaths = new URL('vendor/', location.href).href;
このエラーの経緯は、Claude Codeのエラー・困りごと一覧の「ツール側で出たエラー」に短くまとめています。ここでは、絶対URLにすれば直る、とだけ覚えておけば足ります。
ナンバープレートのぼかしを端末内で:検出は21/25枚、隠し方は白いプレート
ナンバープレートのぼかしを端末の中で済ませる作りでは、YOLOでナンバーの位置を見つけ、その形に合わせて白いプレートをかぶせます。公開されている写真25枚の実測では、21枚で自動検出できました。
検出モデルは実写25枚で比べて決めた
モデルは、ナンバー検出用に公開されているYOLOの小型版から選びました。日本の車・バイク・バスの写真25枚で2つを比べ、取りこぼしが少なかったYOLO11nを採っています。推論サイズを1280にして、小さいナンバーも拾えるようにしました。
自動で正しく検出できたのは25枚中21枚です。弱かったのは、遠くのバスの小さいナンバー、ナンバーの一部しか写っていない接写、そもそも板が写っていない真横からのバイクでした。
誤検出もありました。車体の小さな反射や、ボンネットのステッカーをナンバーと読んだものです。しきい値を0.35にしても一部は残るので、画面の×で消せるようにしています。
取りこぼしは4分割で拾い直す
写真全体で推論して、いちばん高いスコアが0.5未満のときだけ、写真を4つに分けて拡大し直します。分けた4枚は15%ずつ重ね、結果を統合します。
この追加の処理で、遠くのバスのナンバーが0件から検出に変わりました。かかる時間は、該当する写真だけ約0.6秒増えます。全部の写真で4分割しないのは、検出できている写真まで遅くしないためです。
YOLOの出力は [1, 4+クラス数, N] の形で、中心の座標・幅・高さ・スコアが並んでいます。重なった枠を1つにまとめる処理(NMS)は、重なりの基準を0.45にしました。
余白を足すと「錠剤」に見えた
最初は安全のつもりで、検出した枠に左右14%・上下22%の余白を足していました。大きな角丸もつけています。仕上がりを見た僕が「白塗りがデカくてダサい」と指摘し、余白を左右3%・上下5%まで詰めました。
45件の実写で確かめると、検出した枠そのものがナンバーの縁にほぼぴったりでした。余白はほとんど要らなかったのです。画像のエッジから枠を詰める後処理も試しましたが、バンパーの模様を拾って悪化したので採っていません。
斜めのナンバーは四隅を推定して型を取る
検出した枠は水平な長方形なので、斜めに写ったナンバーでは余白が出ます。そこで枠の周りの輪郭を調べ、上下左右の4辺を直線で探して、その交点を四隅にしました。
実写45件のうち35件で4辺が取れ、斜めのナンバーも遠近どおりの形で隠れました。取れなかった写真は長方形のままです。追加の時間は1枚あたり約0.3秒でした。
隠し方は、9月12日の版で純白のプレートに絞りました。僕が「モザイク・黒塗り・地色塗りは不要。真っ白で綺麗に型抜き」と決め、切り替えの画面ごと削っています。自動で100%は約束できないので、漏れはタップで足して手直しする前提です。
COOPとCOEPでcrossOriginIsolatedに:共用レンタルサーバーでもマルチスレッドが効いた
onnxruntime-webを複数のスレッドで動かすには、ページを crossOriginIsolated の状態にします。共用のレンタルサーバーでも、.htaccess にヘッダーを2行足すだけで効きました。
公式ドキュメントでは、env.wasm.numThreads の複数スレッドに条件があります。ブラウザがWASMのマルチスレッドに対応し、crossOriginIsolatedが有効なときだけです。既定の0は環境に合わせた自動です。
MDNによると、crossOriginIsolatedになる条件は2つのヘッダーです。COOP(Cross-Origin-Opener-Policy)は same-origin にします。
COEP(Cross-Origin-Embedder-Policy)は require-corp か credentialless にします。
こうするとSharedArrayBufferが使えるようになります。
僕の会社のツールでは、ツールのフォルダに置いた .htaccess を次のように書きました。関係する行だけの抜粋です。
<IfModule mod_headers.c>
Header set Cross-Origin-Opener-Policy "same-origin"
Header set Cross-Origin-Embedder-Policy "require-corp"
Header set Cross-Origin-Resource-Policy "same-origin"
</IfModule>
AddType application/wasm .wasm
AddType application/javascript .mjs
AddType application/octet-stream .onnx
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE application/wasm
</IfModule>
アプリ側では、crossOriginIsolatedのときだけスレッド数を端末のコア数に合わせ、上限を4にしています。そうでないときは1スレッドで動かします。この形で、Macでは1枚0.2〜1.0秒でした。
require-corp を付けると、許可していない外部の画像やスクリプトは読めなくなります。僕のツールは外部のものを一切読まない作りなので、安全に付けられました。外部の広告タグや解析タグを入れているページでは、同じようにはいきません。
共用サーバーならではの落とし穴もありました。サーバー全体の .htaccess が .json のファイルを拒否していたのです。
アプリの設定ファイル(manifest)は .webmanifest にして、フォルダ側で明示的に許可しています。.wasm の種類も、AddType で書かないと正しく配られません。
iPhoneの罠1:canvasの上限を避けて、書き出しは長辺約4000pxに
iPhoneのSafariで大きな写真をそのままcanvasに描くと、書き出しで失敗するおそれがあります。僕の会社では、書き出しを長辺約4000px(1,250万画素)までに抑えました。
上限の数字は、iOS Safariのcanvasの制限に収まる範囲として決めたものです。Appleの公式の文書で、いまの上限の数字は確かめられていません。ブログに載せる写真なら、この大きさで十分だと判断しました。
書き出しはJPEGの品質92%で、写真の付属情報(EXIF)は外しています。その結果、撮影場所の位置情報も消えます。ナンバーを隠すのと同じ目的で、写真から余計な情報を出さない副次的な効果になりました。
写真の向きはcreateImageBitmapで反映する
縦で撮った写真が横倒しにならないよう、読み込みは createImageBitmap を優先しています。
MDNでは、imageOrientation を from-image にすると、EXIFの向きの情報に従って画像が回ると説明されています(既定の値)。
最初は <img> の decode() で読み込んでいました。ところが、タブが裏に回ったり画面が非表示になったりすると、デコードが保留されて処理が止まります。
9月11日の版で createImageBitmap を先に試し、使えないときだけ <img> に戻す形に変えました。
iPhoneの写真(HEIC)は別の記事に分けた
iPhoneの写真はHEICという形式で届くことがあり、ChromeやEdgeではそのまま読めません。このツールでも同じ9月11日に、変換ライブラリが新しいiPhoneの写真を読めず、入れ替えています。
原因と直し方はHEIC画像がブラウザで読み込めない原因と対処に詳しく書きました。
iPhoneの罠2:共有シートはタップ直後しか開けないので、書き出しを先回りする
iPhoneで加工した写真を写真アプリに入れるには、共有シートを開いて「画像を保存」を選んでもらうのが近道です。ただ共有シートはタップの直後にしか開けないので、書き出しはタップより前に済ませておきます。
MDNでは、navigator.share() はボタンのクリックなど操作の直後(transient activation)でないと呼べないと書かれています。操作の直後でなければ NotAllowedError になります。
ファイルを渡すときは、先に navigator.canShare() で確かめる決まりです。
ここでつまずくのは、書き出しに時間がかかる場合です。タップしてから何秒もかけてJPEGを作っていると、その間に「操作の直後」が切れてしまいます。
そこで、ナンバーの検出と手直しが終わった時点で、保存用のJPEGを先回りで作っておくようにしました。保存ボタンを押したときには、できているファイルを渡すだけです。
まだできていないときは「写真に保存する」ボタンをもう1回押してもらう、2段の構えにしています。
PCでは逆の失敗がありました。Macでも共有シートが開いてしまい、保存しようとしても何も動かない状態になったのです。9月11日の版で、PCは共有を使わずにダウンロードに切り替え、複数枚ならZIPにまとめる形にしました。
判定は、スマートフォンで、かつ canShare がファイルの共有を受け付けるときだけ共有シートを使う、という条件にしています。それ以外はダウンロードです。
Service Workerでモデルをキャッシュ:2回目から約25MBを取り直さない
onnxruntime-webのツールは、最初の1回に推論エンジンとモデルをダウンロードします。Service Workerでこれをキャッシュしておくと、2回目からは取り直さずにすぐ起動し、電波の無い場所でも動きます。
MDNでは、Service Workerは通信の間に入る代理のように働き、ページの要求に自分のキャッシュから応えられると説明されています。使えるのはHTTPSのページだけです。
僕の会社のツールでは、ファイルの種類で2つのやり方を分けました。中身がほとんど変わらないものと、よく直すものとで扱いを変えています。
- 推論エンジンとモデル(
vendor/・model/):キャッシュを優先。キャッシュに無いときだけ取りに行く - 画面とプログラム(HTML・JS・CSS):ネットワークを優先。つながらないときだけキャッシュを使う
この分け方で、重いファイル約25MBは2回目から取り直さず、画面の修正はすぐ届きます。ホーム画面に追加すれば、アプリのように開けます。
直したときは、3か所の版をそろえて上げるのが決まりです。HTMLの読み込みに付けた ?v=、アプリの版の番号、Service Workerのキャッシュ名の3つです。キャッシュ名を変えると、古いキャッシュは次の起動で消えます。
画面の小さな罠もありました。自分のCSSで display:flex を当てた要素は、HTMLの hidden 属性を付けても消えません。
対策として、CSSの最初に [hidden]{display:none!important} を入れています。これで属性どおりに消えます。
ナンバーを隠すアプリとして使うときの注意:自動は100%ではない
ナンバーを隠すアプリとしてこの作りを使うなら、自動で拾えない写真が必ず出る前提で、手直しの画面までセットで作ります。自動検出は実測で25枚中21枚でした。
僕の会社のツールでは、タップでカバーを足し、ドラッグで動かし、四隅の丸を引いて形を合わせられます。
編集の画面はピンチで最大8倍まで拡大でき、等倍で見ながら微調整もできます。等倍の表示は、僕が「等倍で確認しながら微調整」したいと頼んで足した機能です。
精度をもっと上げる手として、外部のAIのAPIでナンバーの位置を探す案も比べました。精度は高いものの、中継の仕組みと鍵の管理が要り、写真が外部のサービスに渡ります。写真を外に出さない、という最初の目的と合わないので見送っています。
モデルのライセンスも確かめておきます。使ったナンバー検出のモデルはAGPL-3.0なので、ページの下にクレジットを書きました。onnxruntime-webはMITです。
取引先に使ってもらった結果の取りこぼしの率は、記録の上ではまだ集められていません。弱い場面が多ければ、しきい値と余白から見直し、それでも足りないときに外部AIの精度を上げる案を考えます。
同じ作りは、モデルを差し替えれば別の用途にも使えると考えています。人の顔を隠す、書類の個人情報を隠す、写真を自動で切り抜く、などです。業務の中で「写真を外に出したくない」場面がある方は、ウラカタのサービス一覧も見てみてください。
まとめ
- onnxruntime-webなら、推論エンジンとONNXのモデルを置くだけで、画像のAI推論をブラウザの中で完結できる。写真は端末から出ない
- 置き場所の
wasmPathsは絶対URLにする。相対パスだとno available backend foundになる - COOPとCOEPのヘッダーでcrossOriginIsolatedにすると、共用レンタルサーバーでもマルチスレッドが効いた(Macで1枚0.2〜1.0秒)
- iPhoneは書き出しを長辺約4000pxに抑え、共有シートはタップの直後に開けるよう、保存用のJPEGを先回りで作る
- 僕なら最初に、モデルを1つ決めてWASMの1スレッドで動かし、次にヘッダーで速くする順で始めます
よくある質問
onnxruntime-webはサーバーなしで動きますか?
推論はブラウザの中で動くので、AI用のサーバーは要りません。ただし推論エンジンとモデルのファイルを置く場所は要ります。僕の会社では、既存の共用レンタルサーバーに静的ファイルとして置き、追加の費用は0円でした。
onnxruntime-webをマルチスレッドで動かす条件は?
ブラウザがWebAssemblyのマルチスレッドに対応し、ページがcrossOriginIsolatedになっていることです。そのためには、Cross-Origin-Opener-Policy: same-origin と Cross-Origin-Embedder-Policy: require-corp(またはcredentialless)のヘッダーを返します。
ナンバーを隠すアプリで写真がどこかに送られることはありますか?
僕の会社で作った作りでは、写真は端末の中だけで処理し、サーバーにもAIのAPIにも送りません。ほかのアプリを使う場合は、写真をサーバーへ送る作りかどうかを、提供元の説明で確かめてください。
iPhoneで加工した画像を写真アプリに保存するには?
Web Share APIで共有シートを開き、「画像を保存」を選んでもらう方法があります。共有シートはボタンのタップ直後にしか開けないので、書き出しはタップより前に済ませておきます。
