AI Dev Lab
ToonCast

ToonCast ができるまで — AnimeGANv2 をブラウザで動かす

AnimeGANv2 の小さな ONNX (約9MB) を onnxruntime-web (単一スレッド WASM=COOP/COEP不要、 color-revive で承認済みライブラリの再利用) で実行。 512x512・[-1,1] 正規化で推論し、 結果を元解像度に戻して表示する設計記録。 写真は端末内処理。

「写真をアニメ風にする」アプリはたくさんありますが、その多くは写真をサーバーにアップロードして変換します。顔写真を他人のサーバーに送るのは、正直に言って気が進みません。

ToonCast は、写真をアニメ・イラスト風のタッチに変換するツールです。ポイントは、AI モデルがブラウザの中で動くこと。写真は端末から一歩も外に出ません。

これは「ブラウザで AI」シリーズの 1 本で、以前作った白黒写真カラー化(ColorRevive)で導入した仕組みを、そのまま別のモデルで使い回しています。


🎯 なぜオンデバイスにこだわるのか

顔写真は、最もプライベートなデータの一つです。それをアニメ風にしたいだけなのに、変換のために見知らぬサーバーへ送り、そこで保存されるかもしれない——この不安が、この種のサービスには常につきまといます。

ToonCast は変換をすべてブラウザ内で行うので、写真がネットワークに乗りません。「アップロードしません」という主張ではなく、送る先が実装されていないという構造で、それを保証しています。開発者ツールのネットワークタブを見れば、画像が送信されていないことは誰でも確認できます。


🏗️ ライブラリは増やさず、モデルだけ替える

ToonCast が使うのは AnimeGANv2 という、写真をアニメ調に変換する学習済みモデルです。これを ONNX 形式(約 8.6MB)にしたものを、onnxruntime-web で動かしています。

重要なのは、このランタイム(onnxruntime-web)は新規導入ではないことです。白黒写真カラー化の ColorRevive で既に使っていたもので、ToonCast は同じランタイムに別のモデルを載せているだけ。ライブラリを増やさずに、まったく違うサービスが 1 本立ちます。

const ORT_VERSION = "1.20.1"
const ORT_CDN = `https://cdn.jsdelivr.net/npm/onnxruntime-web@${ORT_VERSION}/dist/ort.min.js`
const MODEL_URL = "https://raw.githubusercontent.com/josephrocca/anime-gan-v2-web/main/anime-gan-v2.onnx"

ランタイムは CDN からスクリプトタグで読み込み、モデルは初回だけ取得。以降はキャッシュから読まれます。

単一スレッドに固定する理由

一つ、意図的な制約があります。WASM を単一スレッドに固定していることです。

ort.env.wasm.numThreads = 1

マルチスレッド WASM は速い代わりに、SharedArrayBuffer を使うため、サーバーが特殊なHTTP ヘッダ(COOP / COEP)を返す必要があります。ところがこのヘッダを入れると、広告(AdSense)などの外部埋め込みが動かなくなることがある。

推論速度と、サイト全体の運用(広告との共存)を天秤にかけて、単一スレッドを選びました。変換に数十秒かかりますが、そのために特殊なヘッダを全ページに強制するのは割に合わない、という判断です。


🔢 前処理と後処理:モデルが期待する形に整える

機械学習モデルは、決まった形式の数値しか受け取りません。AnimeGANv2 の入力は「512×512、RGB、チャンネルが先(NCHW)、値の範囲 -1〜1」という指定です。写真の Canvas をこの形に変換するのが前処理です。

// 元画像 canvas → 512x512 NCHW RGB float32 [-1,1]
out[i]             = (d[i * 4]     / 255) * 2 - 1  // R
out[plane + i]     = (d[i * 4 + 1] / 255) * 2 - 1  // G
out[2 * plane + i] = (d[i * 4 + 2] / 255) * 2 - 1  // B

Canvas のピクセルは [R,G,B,A,R,G,B,A,...] と 1 ピクセルずつ並んでいますが、モデルは [R,R,R,...,G,G,G,...,B,B,B,...] とチャンネルごとにまとまった並び(プレーン)を期待します。plane はその 1 チャンネル分のサイズで、R/G/B を別々の区画に振り分けているのがこの部分です。(px/255)*2-1 で 0〜255 を -1〜1 に正規化しています。

出力は逆の変換です。モデルが返す -1〜1 の数値を、画像として表示できる 0〜255 に戻します。

// モデル出力 [-1,1] NCHW → 512x512 ImageData
img.data[i * 4]     = clamp((out[i]             * 0.5 + 0.5) * 255)  // R
img.data[i * 4 + 1] = clamp((out[plane + i]     * 0.5 + 0.5) * 255)  // G
img.data[i * 4 + 2] = clamp((out[2 * plane + i] * 0.5 + 0.5) * 255)  // B

(v*0.5+0.5)*255 が -1〜1 を 0〜255 に戻す式で、clamp は範囲外の値を切り詰める安全策です。

AI 変換の実装は、実は「変換そのもの」より前後の形式合わせが大半です。モデルは run() を呼ぶだけですが、その前後で「モデルが読める形にする/画面に出せる形に戻す」ところで細かいミスが起きやすい。チャンネルの並び順や正規化の式を一つ間違えると、色が反転したり、変な縞模様になったりします。


😓 苦労したところ

サンプル画像は「実写」でないと意味がない

これは作ってみて最も痛感した点です。

最初、サンプル画像に手描き風のイラストを置いていました。ところがこれだと「すでにアニメっぽい絵をアニメ化する」ことになり、変換の効果がまったく分からない。ビフォーとアフターがほぼ同じに見えてしまうのです。

変換の威力を見せるには、サンプルは実写の写真でなければなりません。実写がアニメ調の線と塗りに変わって初めて、「おお、変わった」と伝わる。そこで、商用利用可能なフリー写真(人物・風景・街並み)を実際のサンプルとして同梱し、端末内に data URL で埋め込みました。外部への取得も発生しません。

写真を変換・解析する AI サービスでは、サンプルは必ず実写にする——これは他のサービスでも繰り返し確認した教訓です。

仕上がりは写真しだい、と正直に書く

AnimeGANv2 の出力は、元の写真の明るさ・構図・被写体で大きく変わります。同じ人でも、明るくて顔がはっきりした写真ならきれいに決まり、暗くて小さく写った写真ではぼんやりする。

ここを「どんな写真でも完璧にアニメになる」と見せると、期待外れになります。なので UI と説明に「仕上がりは写真によって変わる」ことを明記し、うまくいかないときは別の写真で試すよう案内しています。AI の出力を過大に見せない、という正直さは、この種のサービスでは信用に直結します。

見た目は自動テストで確認できない

変換が「動いた」ことは、出力画像が元と十分違うか(平均的な色の差など)で数値的にチェックできます。でも「アニメ調としてちゃんと見えるか」は、数値では測れません。

ここは実際に目で見て確かめるしかない部分です。技術的に処理が通っていることと、絵として気持ちよく仕上がっていることは別物で、後者は最終的に人の目で判断するしかない、という領域が残ります。


🔭 今後の拡張

  • 変換の強さ調整 — 元写真とのブレンド比を変えて、ほんのりアニメ調〜がっつりアニメ調まで
  • 複数スタイル — 違う画風のモデルを選べるように
  • 顔だけ変換 — 背景はそのままに顔だけアニメ化する
  • 高解像度化 — 512×512 で処理した結果を、別の超解像モデルで引き上げる

最後のひとつは、同じくブラウザで動く超解像(PixelLift)と組み合わせられそうで、オンデバイス AI どうしを連結する面白さがあります。


💡 このサービスから言えること

ランタイムを一度用意すれば、モデルを替えるだけで別サービスが生まれる。 onnxruntime-web を ColorRevive で導入したときは白黒写真のカラー化でしたが、同じランタイムに AnimeGANv2 を載せれば写真のアニメ化になる。新しいことをやるのに新しいライブラリは要らず、手元のインフラの上でモデルを差し替えるのが一番速い。依存も増えません。

技術的には、AI 変換の実装の大半が前処理と後処理だという実感が残りました。モデルを呼ぶのは一行ですが、チャンネルの並びと正規化の式を写真に合わせて正しく往復させるところに、実装の神経を使います。

そして、顔写真という繊細なデータを扱う以上、ブラウザ内で完結して外に送らないことは、機能ではなく前提です。運用(広告との共存)のために推論速度を諦めてでも、その前提を崩さない設計を選びました。

[ ./next_action ]

読んだら、 ToonCast を実際に動かす。

この開発ログは ToonCast をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。

[ ./related_logs ]

関連する開発ログ

all logs →
PhotoTwin

PhotoTwin ができるまで — CLIP画像埋め込みで似た写真を見つける

CLIP (Xenova/clip-vit-base-patch32) の image-feature-extraction を transformers.js の CDN ESM で side-load し、 各写真を正規化ベクトル化。 cosine 類似度で重複・似た写真をブラウザ内で検出する設計記録 (新ライブラリ追加なし=what-cam と同じ CLIP の再利用)。

read log →
WhatCam

WhatCam ができるまで — CLIP のゼロショット画像分類をブラウザで動かす

CLIP (Xenova/clip-vit-base-patch32) を transformers.js の CDN ESM で side-load し、 写真と候補ラベルの近さをブラウザ内で計算。 日本語ラベルを英語プロンプトに変換し、 図鑑と自由入力の両モードで「これ何?」を判定する設計記録。

read log →
DepthCast

DepthCast ができるまで — 1枚の写真をAIの深度推定で立体にする

Depth Anything (transformers.js) を CDN ESM で side-load し、 1枚の写真から深度マップを推定。 WebGL2 フラグメントシェーダで深度に比例した視差 (iterative backward parallax) を作り、 赤青アナグリフ / WebM 書き出しまで端末内で完結させた設計記録。

read log →
GanttPad

GanttPad ができるまで — 日付軸タイムラインのレイアウト計算

タスクの開始/終了日から全体期間を求め、 各バーの offset(日数) と duration を算出して SVG に配置。 期間に応じて日/週/月の目盛りを自動切替し、 今日ライン・週末シェード・進捗塗りを描く。 プランニングボード motif で 組んだ サービス設計記録。

read log →
HeirShare

HeirShare ができるまで — 相続税の早見表ロジックを実装する

家族構成から法定相続人と法定相続分を判定し、 基礎控除 → 課税遺産総額 → 法定相続分按分 → 速算表 → 相続税の総額 → 配偶者の税額軽減 (法定相続分 or 1.6億) という早見表と同じ流れを純 JS で実装。 相続関係図 motif で 組んだ サービス設計記録。

read log →
GridNine

GridNine ができるまで — 唯一解の数独をブラウザで生成する

MRV バックトラッキングで完成盤を作り、 セルを抜くたびに解の個数を 2 で打ち切り数えて『唯一解』を保ったまま穴掘り。 候補メモ・重複ハイライト・ヒント・タイマーを 文庫パズル誌 motif で 組んだ サービス設計記録。

read log →