AI Dev Lab
ColorRevive

ColorRevive ができるまで — onnxruntime-web で白黒写真をカラー化

DeOldify の量子化 ONNX を onnxruntime-web (CDN side-load・単一スレッド WASM=COOP/COEP不要) で実行。 256x256 でモデル推論し、 輝度は元写真・色だけ AI を YCbCr で再合成して輪郭を保つ設計記録。 写真は端末内処理。

ColorRevive は、白黒写真に色を付けるツールです。DeOldify の ONNX モデルを onnxruntime-web でブラウザ内推論し、出力の色だけを使って元写真の輝度と合成しています。写真はどこにも送りません。

#🎯 モデルの出力をそのまま出すと輪郭がぼける

カラー化モデルは 256×256 で推論します。ここが実装上の最大の制約でした。

素直に「モデルの出力を元サイズに拡大して表示」すると、細部が全部 256×256 相当までぼけます。顔も、文字も、建物の輪郭も。色は付いていますが、写真としては劣化しています。

そこで発想を変えました。

色だけモデルからもらい、明るさは元写真から使う。

人間の目は輝度の解像度に敏感で、色の解像度には鈍感です(テレビ放送やJPEGが色差を間引いているのと同じ理屈)。だから色が低解像度でも、輝度が原寸なら写真は原寸に見えます

#🎨 YCbCr で輝度と色差を分ける

実装は RGB を YCbCr に分解して、輝度 Y を元写真から、色差 Cb/Cr をモデル出力から取ります。

ts
// 元写真から 輝度 Y を計算
const yo = 0.299 * od[p*4] + 0.587 * od[p*4+1] + 0.114 * od[p*4+2]

// モデル出力(拡大済み)から 色差 Cb / Cr を計算
const cb = (128 - 0.168736*r - 0.331264*g + 0.5*b - 128) * strength
const cr = (128 + 0.5*r - 0.418688*g - 0.081312*b - 128) * strength

// 元の輝度 + モデルの色差 で RGB に戻す
result.data[p*4]     = clamp(yo + 1.402 * cr)
result.data[p*4 + 1] = clamp(yo - 0.344136*cb - 0.714136*cr)
result.data[p*4 + 2] = clamp(yo + 1.772*cb)

係数は ITU-R BT.601 の標準変換式です。- 128 してから * strength を掛けているのがポイントで、Cb/Cr は 128 が無彩色の中心なので、中心からのずれに係数を掛けることで彩度を調整できます。

strength = 0 なら完全な白黒、1 ならモデルの色そのまま。色の強さをスライダーにできるのは、この分解をしているおかげです。

この方式にした結果、細部は元写真のまま、色だけが乗るという状態になりました。輪郭が一切ぼけません。

#📦 前処理 ── 256×256 の Float32 プレーンに詰める

モデルへの入力は、256×256 の RGB をチャンネルごとに並べた形(CHW)です。Canvas の ImageData はピクセルごとに RGBA が並ぶ(HWC)ので、詰め替えが要ります。

ts
export function canvasToInput256(src: HTMLCanvasElement): Float32Array

R が 65536 個、次に G が 65536 個、最後に B ── という順に詰めます。出力も同じ並びで返ってくるので、読み出すときは out256[i]out256[plane + i]out256[2 * plane + i]プレーンの先頭にオフセットを足してアクセスします。

ts
img.data[i * 4]     = clamp(out256[i])              // R プレーン
img.data[i * 4 + 1] = clamp(out256[plane + i])      // G プレーン
img.data[i * 4 + 2] = clamp(out256[2 * plane + i])  // B プレーン

ここを取り違えると、色がおかしいだけで動いてしまうので気づきにくいバグになります。

#🧪 「色が付いたか」を数値で確かめる

見た目の確認だけだと、テストが書けません。そこで最大彩度を測る関数を用意しました。

ts
/** 画像の最大彩度 (0..255)。 e2e で「色が付いたか」を測るのに使う。 */
export function maxSaturation(data: Uint8ClampedArray): number

白黒写真は彩度がほぼ 0 で、カラー化後は明確に上がります。「処理して彩度が上がったか」を自動で確かめられるので、モデルの読み込みや前処理が壊れたときに気づけます。

画像処理は「動いているように見えて何もしていない」が起きやすい領域です。以前、別のサービスで出力が入力そのままになっていたことがあり、それ以降出力を数値で検査する関数を必ず用意するようにしています。

#😓 苦労したところ

モデルのダウンロードが重い。 初回は数十 MB を落とします。進捗を出し、一度読んだらブラウザにキャッシュされることを明記しました。ここを黙って待たせると、ほぼ全員が離脱します。

サンプルに実写を使う。 図形やイラストをサンプルにすると、カラー化されても「本当に効いているのか」が伝わりません。かといって実写を同梱するとサイズが増えます。SVG で写実的な陰影を持つ絵を描いて同梱し、それを白黒にしたものを初期表示にしました。

これは以前、別の写真変換ツールでサンプルが抽象的すぎて効果が伝わらなかった反省からです。変換系は、変換前後が一目で分かる素材でないと機能を説明できません。

古い写真の粒子。 スキャンした古写真はノイズが多く、そのまま入れると色がまだらになります。前処理で軽く平滑化する案もありましたが、輝度は元写真のまま使うという方針と衝突するのでやめました。ノイズも含めて元の質感、という割り切りです。

#🔭 今後の拡張

  • 部分的に色を指定する(この服は青、という手がかりを与える)
  • 複数モデルの切り替え(絵画向け / 人物向け)
  • 変換前後のスライダー比較(現在は並べて表示)
  • 解像度の高いモデルへの差し替え

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

モデルの制約は、合成の工夫で回避できることがあります。 256×256 という制約は変えられませんが、「輝度は元、色はモデル」に分ければ原寸の写真が作れました。モデルを大きくするより、出力の使い方を変えるほうが効いた例です。

そして人間の知覚の性質(輝度に敏感、色に鈍感)は、画像処理で何度も使える知識でした。JPEG も動画コーデックも同じ前提に立っています。

[ ./next_action ]

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

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

[ ./related_logs ]

関連する開発ログ

all logs →
TurnStone

TurnStone ができるまで — リバーシ AI の読みを盤に出す

リバーシ AI 対戦の開発ログ。 評価関数を 1 つの数値に潰さず 位置 ・ 打てる手 ・ 角 ・ 確定石 ・ 石数 の内訳で返す形にし、 置けるマス全部に評価値を重ねた記録です。 全手の点数を正しく出すために、 根での枝刈りを捨てた話も書いています。

read log →
ToonCast

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

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

read log →
PhotoTwin

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

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

read log →
AkinFind

AkinFind ができるまで — ブラウザ内で完結する embeddings 意味検索

多言語の文章埋め込みモデル (Xenova/multilingual-e5-small) を transformers.js の CDN ESM で side-load し、 各文を正規化ベクトル化。 cosine 類似度で意味検索と似ている文ペア検出を全て端末内で行う設計記録。

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 →