EXIFに埋め込んだ深度マップで写真を立体表示した
画像のEXIF情報に深度マップを埋め込んで、それを読み込んで立体表示する。深度推定は不要。すでに深度マップが含まれた画像を使う。
深度マップのデータをBase64エンコードして、EXIF UserCommentに格納。読み込み時にデコードして、レイヤーに分割。
やりたかったこと
深度マップ付きの写真を配布したかった。
前回(midori266)は、画像を読み込んで、その場で深度推定してた。でも、毎回推論するのは無駄。推論には約1秒かかる。
深度マップを事前に生成して、画像に埋め込めないか。
EXIF UserCommentに、Base64エンコードした深度マップを格納することにした。
深度マップの埋め込み
深度マップをBase64エンコードして、EXIFに埋め込む。
// 深度マップをCanvas経由でBase64に変換
function encodeDepthMapToBase64(depthData, width, height) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
// ImageDataを作成
const imageData = ctx.createImageData(width, height);
// 深度データを0-255にマッピング
for (let i = 0; i < depthData.length; i++) {
const value = Math.floor(depthData[i] * 255);
imageData.data[i * 4] = value;
imageData.data[i * 4 + 1] = value;
imageData.data[i * 4 + 2] = value;
imageData.data[i * 4 + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
// CanvasをBase64に変換
return canvas.toDataURL('image/png').split(',')[1];
}
// EXIFに書き込み
const base64DepthMap = encodeDepthMapToBase64(depthData, 512, 512);
exifObj['Exif'][piexif.ExifIFD.UserComment] = 'DepthMapData:' + base64DepthMap;
const exifBytes = piexif.dump(exifObj);
const imageWithExif = piexif.insert(exifBytes, originalImageDataUrl);
UserCommentフィールドに、DepthMapData:というプレフィックスをつけて、Base64データを格納。
最初は、JSONでメタデータも一緒に埋め込もうとした。でも、JSONのパースが面倒だった。Base64だけにした。
深度マップの読み込み
EXIF UserCommentから深度マップを読み込む。
async function loadDepthMapFromExif(file) {
// ファイルをバイナリとして読み込み
const binaryStr = await readFileAsBinaryString(file);
// EXIFデータを解析
const exifData = piexif.load(binaryStr);
const userComment = exifData['Exif'][piexif.ExifIFD.UserComment];
// UserCommentからBase64データを取得
if (!userComment || !userComment.startsWith('DepthMapData:')) {
throw new Error('この画像には深度情報が含まれていません');
}
const base64Data = userComment.substring('DepthMapData:'.length);
// Base64をデコードして画像として読み込み
const depthImage = new Image();
depthImage.src = 'data:image/png;base64,' + base64Data;
await depthImage.decode();
// Canvasで画像をピクセルデータに変換
const canvas = document.createElement('canvas');
canvas.width = depthImage.width;
canvas.height = depthImage.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(depthImage, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const depthArray = new Float32Array(imageData.width * imageData.height);
// ピクセルデータを0-1の深度値に変換
for (let i = 0; i < depthArray.length; i++) {
depthArray[i] = imageData.data[i * 4] / 255.0;
}
return { depthArray, width: canvas.width, height: canvas.height };
}
Base64をデコードして、Imageオブジェクトとして読み込み。そこからCanvasでピクセルデータを取得。
レイヤー分割と3D配置
深度マップが取得できたら、あとはmidori266と同じ。
8層のレイヤーに分割して、3D空間に配置。レイヤー間隔は5.0。
function splitImageIntoLayers(imageData, depthData, layerCount) {
const layers = [];
// 深度の範囲を計算
let minDepth = Infinity, maxDepth = -Infinity;
depthData.forEach(d => {
minDepth = Math.min(minDepth, d);
maxDepth = Math.max(maxDepth, d);
});
// レイヤーごとに分割
for (let i = 0; i < layerCount; i++) {
const layerCanvas = document.createElement('canvas');
layerCanvas.width = imageData.width;
layerCanvas.height = imageData.height;
const ctx = layerCanvas.getContext('2d');
const layerImageData = ctx.createImageData(imageData.width, imageData.height);
// 深度の閾値
const depthThresholdMin = minDepth + (maxDepth - minDepth) * (i / layerCount);
const depthThresholdMax = minDepth + (maxDepth - minDepth) * ((i + 1) / layerCount);
// ピクセルをコピー
for (let j = 0; j < depthData.length; j++) {
if (depthData[j] >= depthThresholdMin && depthData[j] < depthThresholdMax) {
layerImageData.data[j * 4] = imageData.data[j * 4];
layerImageData.data[j * 4 + 1] = imageData.data[j * 4 + 1];
layerImageData.data[j * 4 + 2] = imageData.data[j * 4 + 2];
layerImageData.data[j * 4 + 3] = 255;
} else {
layerImageData.data[j * 4 + 3] = 0;
}
}
ctx.putImageData(layerImageData, 0, 0);
layers.push(layerCanvas);
}
return layers;
}
// 3D配置
function createLayerMeshes(layers, spacing) {
const meshes = [];
layers.forEach((layerCanvas, index) => {
const texture = new THREE.CanvasTexture(layerCanvas);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide
});
const geometry = new THREE.PlaneGeometry(160, 90);
const mesh = new THREE.Mesh(geometry, material);
mesh.position.z = index * spacing;
mesh.renderOrder = index;
meshes.push(mesh);
scene.add(mesh);
});
return meshes;
}
const layerMeshes = createLayerMeshes(layers, 5.0);
ハマったところ
EXIF UserCommentのサイズ制限
最初、UserCommentに512×512のBase64データを入れようとした。
サイズが大きすぎて、エラーになった。UserCommentには約65KBまでしか入らない。
512×512のPNGをBase64エンコードすると、約300KBになる。入らない。
深度マップを256×256にリサイズした。約75KB。ギリギリ入らない。
128×128にしたら、約20KB。余裕で入った。
でも、128×128だと、精度が低い。レイヤーの境界がぼやけた。
結局、品質を落として圧縮した256×256を使うことにした。約50KBで収まった。
EXIF書き込み後のファイルサイズ
EXIF情報を書き込んだら、ファイルサイズが増えた。
元の画像が2MBだったのが、2.05MBになった。約50KBの増加。
まあ許容範囲。配布する画像としては問題ない。
Base64デコードのエラー処理
Base64データが壊れてると、デコード時にエラーになる。
最初は、try-catchで囲んでなかった。エラーが出ると、アプリ全体が止まった。
// NG: エラー処理なし
const base64Data = userComment.substring('DepthMapData:'.length);
const depthImage = new Image();
depthImage.src = 'data:image/png;base64,' + base64Data;
try-catchで囲んで、エラーメッセージを表示するようにした。
// OK: エラー処理あり
try {
const base64Data = userComment.substring('DepthMapData:'.length);
const depthImage = new Image();
depthImage.src = 'data:image/png;base64,' + base64Data;
await depthImage.decode();
} catch (err) {
console.error('深度マップの読み込みに失敗:', err);
alert('深度マップの読み込みに失敗しました');
return;
}
パフォーマンス
深度推定が不要なので、高速。
| 処理 | 時間 |
|------|------|
| EXIF読み込み | 約0.1秒 |
| Base64デコード | 約0.2秒 |
| レイヤー分割(8層) | 約0.5秒 |
| 3D配置 | 約0.1秒 |
| 合計 | 約0.9秒 |
midori266は約1.6秒だったので、約0.7秒の短縮。
スマホでも、約1.5秒で表示される。midori266は約4秒だったので、大幅に高速化。
結果
EXIF深度マップで、写真を高速に立体表示できた。
- EXIFに深度マップを埋め込み(Base64、約50KB)
- UserCommentフィールドに格納
- 読み込み時にデコード(約0.9秒)
- 8層のレイヤーに分割(midori266と同じ)
- 3D空間に配置(レイヤー間隔5.0)
深度推定を事前に行うことで、約0.7秒の高速化。
深度マップ付きの写真を配布して、他の人も立体表示できるようになる。
まとめ
今回は、EXIFに深度マップを埋め込んで、高速に立体表示する実験を行いました。
ポイントは以下の3つ:
- EXIF UserCommentに深度マップを埋め込み(Base64、256×256、約50KB)
- 読み込み時にデコード(約0.9秒)
- midori266より約0.7秒高速化
深度マップのサイズと圧縮率の調整が重要だった。
深度マップ付き写真の配布に興味がある方の参考になれば嬉しいです。
さらに深く学ぶには
この記事で興味を持った方におすすめのリンク:
自分の関連記事:
最後まで読んでくださり、ありがとうございました。