DepthCast ができるまで — 1枚の写真をAIの深度推定でブラウザだけで立体にする
DepthCast は、写真を1枚入れるだけで 奥行き (深度) を AI が推定し、手前と奥で動かす量を変えて立体 (視差・パララックス) アニメ にする Webサービスです。深度推定モデル Depth Anything を transformers.js で読み込み、視差の描画は WebGL2 のシェーダで行います。深度推定も描画もすべてブラウザの中で完結し、写真はサーバーに送りません。赤青メガネで見るアナグリフ画像や、ぐるりと動く3Dの動画 (WebM) も書き出せます。ラボの「AIが主役」路線、オンデバイスML の1本目として作りました。
なぜこの形にしたか
「写真の3D化」は本来ステレオカメラや3Dスキャンが要る領域でしたが、単眼深度推定 (monocular depth estimation) の精度がここ数年で一気に上がり、1枚の普通の写真から「どこが手前でどこが奥か」を読み取れるようになりました。Depth Anything はその代表格で、量子化した小型版なら数十MBでブラウザに載る規模です。
ステレオでも3Dモデルでもない、スマホで撮っただけの1枚が揺れて立体に見える — この「え、これ動くの?」の驚きが体験の核なので、深度マップという中間生成物も画面に見せる方針にしました。AIが写真から奥行きを読み取った結果を、その場で目で確認できます。
visual direction
アナグリフ / 3Dシネマ。
- チャコール地 (
#16181d) に、アナグリフ赤 (#ef4b54) とシアン (#2fc6d6) の2色ずれ - 上部にフィルムのパーフォレーション (送り穴) を敷いて「映写・シネマ」のメタファー
- 赤青の色は「赤青3Dメガネ」= 深度・立体という題材そのものから引いた。既存の紙・パステル・和紙系とは別軸のダーク基調
実装の見どころ
1. 深度推定は transformers.js に、視差描画は自前シェーダに分ける
深度の推定は Depth Anything (Xenova/depth-anything-small-hf) に任せ、そこから先の「奥行きをどう動かして立体に見せるか」は自前で書きました。パイプラインは1行です。
const pipe = await pipeline("depth-estimation", "Xenova/depth-anything-small-hf", {
quantized: true,
})
const out = await pipe(imageURL)
// out.depth は min-max 正規化済みのグレースケール (0 = 奥, 255 = 手前)
出力は画像サイズに揃った深度マップ。これを WebGL のテクスチャに渡して、ピクセルごとに「手前ほど大きくずらす」だけで視差が生まれます。
2. 視差は「後ろ向きに反復サンプリング」するフラグメントシェーダ
素朴に「深度に比例して前景をずらす」と、ずらした先に穴 (occlusion hole) が開きます。DepthCast は逆に、出力側の各ピクセルから深度を見て入力座標を引き戻す backward warping を数回反復して、穴の少ない視差を作っています。
vec2 uv = vUv;
vec2 p = uv;
for (int i = 0; i < 5; i++) {
float dep = texture(uDepth, p).r; // その位置の深度
p = uv + (dep - 0.5) * uAmp * uOffset; // 深度に比例してサンプル位置を補正
}
outColor = texture(uImage, p);
uOffset は視点の動き (円運動・左右・上下)、uAmp は立体の強さ。5回まわすと、手前と奥の境目でも破綻しにくくなります。
3. 動きは cos/sin のオフセットだけで作る
「ぐるり」「左右」「上下」の3モードは、時間 t から uOffset を作るだけです。重い再計算はなく、深度テクスチャは一度アップロードすれば使い回します。
// orbit: 円を描く / horizontal: 左右 / vertical: 上下
ox = Math.cos(t * omega); oy = Math.sin(t * omega)
4. 赤青3D (アナグリフ) は左右2視点を1枚に合成
左目 (offset -1) と右目 (offset +1) を別々にレンダリングし、左目から赤チャンネル、右目からシアン (緑+青) を取って1枚に重ねると、赤青メガネで立体に見える静止画になります。
comb.data[i] = left.data[i] // R ← 左目
comb.data[i + 1] = right.data[i + 1] // G ← 右目
comb.data[i + 2] = right.data[i + 2] // B ← 右目
動く3Dは canvas.captureStream(30) を MediaRecorder に流して WebM で書き出します。
苦労したところ
-
transformers.js を Turbopack 経由で import すると落ちる:
await import("@xenova/transformers")を直接書くと、Next.js 16 のチャンクローダーがモジュールを取り違えてCannot convert undefined or null to objectで死ぬ。対処は CDN の ESM (cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2/dist/transformers.min.js) を変数化した URL で side-load し、バンドラを経由しないこと。翻訳の word-warp で最初に踏んだ罠と同じで、ML系サービスはこの経路で統一しています。 -
サンプル画像がベタ塗りだと精度が出ない: 公開直後、用意したお手本 (コードで描いた平面的なイラスト) で「精度が悪い」と指摘を受けました。原因はモデルではなく サンプル側でした。深度推定モデルは、テクスチャの粗密・陰影・かすみ (大気遠近)・重なり といった手がかりから奥行きを読みます。フラットな色面はその手がかりが乏しく、深度マップがガタつく。そこでサンプルを 山あいの霧 (奥ほど霞む多層の稜線)・並木道 (消失点への線遠近)・机の上 (手前に大きく陰影を付けた球) に描き直し、全面に連続グラデと微細なノイズを入れて平面を排除しました。実際に撮った写真ならもともと精度は出るので、「実写真を入れると立体感が上がる」ガイドも画面に明示しています。合成サンプルは AI の実力を示せない、という教訓です。
-
深度マップの見た目は自動テストでは担保できない: 「深度が near→far の幅を持つ」「アナグリフに左右のずれがある」までは自動で検証できますが、立体アニメが自然に見えるかは実機で目視するしかありません。ここは正直に実機確認前提にしています。
今後の拡張
- 深度からのメッシュ書き出し: 深度マップを高さ場として glTF / OBJ に変換し、3Dビューアで回せるように
- 被写界深度 (ボケ) の後付け: 深度を使って背景だけぼかすポートレート風フィルタ
- バッチ処理: 複数枚をまとめて3D化して連番WebMに
このサービスから言える事
「写真を立体にするには特別な機材が要る」という前提は、単眼深度推定がブラウザで動くようになった時点で崩れています。DepthCast はその変化を、モデルのダウンロードと数十行のシェーダだけで、誰の端末の中でも起こせる形にしたものです。深度推定は3D化だけでなく、ボケ・切り抜き・合成の土台にもなるので、この1枚から読み取った奥行きは今後いろいろな加工の入口になります。
[ ./next_action ]
読んだら、 DepthCast を実際に動かす。
この開発ログは DepthCast をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。