从照片到全息:普通图片 3D 全息效果展示

2026-03-22codingthreejsaigraphics

展示效果

代码实现完成于半年前,本文由 claude opus 4.6 协助创造。

起因

之前看到苹果 ios26 里相册原生支持了图片伪 3d 展示的功能,觉得挺有意思。

一张普通的 2D 照片,如果能根据画面内容产生真实的深度感,转动的时候前景和背景有视差——那不就是全息的感觉了吗?

能不能自动把一张普通照片变成全息图?

直接在网页中展示,让安卓也能体验到类似的效果。

核心思路很简单:

  1. 用 AI 模型估计照片的深度信息
  2. 把图片按深度分层(前景/背景)
  3. 用 Three.js 把多个层叠在一起做 3D 渲染
  4. 手机上用陀螺仪控制视角,电脑上用鼠标拖拽

处理图片

1. 深度估计

使用 Depth-Anything-V2 模型生成深度图。这个模型效果相当不错,能从单张照片中估计出比较准确的深度关系。

python test.py input.png gray.png

输入一张照片,输出一张灰度深度图,越亮的像素表示离相机越近。

对性能的要求也不高,直接用电脑跑就挺快的,手机硬件应该也足够。

2. 基于梯度的图层分割

有了深度图之后,关键问题是:怎么把前景和背景分开?

最直观的方法是按灰度值阈值切割,但这样边缘会很生硬。我实际采用用了基于 Sobel 算子的梯度分割方案:

// Sobel 算子
const sobelX = [
  [-1, 0, 1],
  [-2, 0, 2],
  [-1, 0, 1],
];

const sobelY = [
  [-1, -2, -1],
  [0, 0, 0],
  [1, 2, 1],
];

对深度图每个像素做 3×3 卷积,计算梯度幅值。梯度大的地方就是深度突变的边缘——也就是前景和背景的分界线。

具体流程:

  1. 计算梯度:对每个像素施加 Sobel 算子,得到水平和垂直方向的梯度
  2. 阈值检测:标记梯度幅值超过阈值的像素为边缘
  3. 连通域分析:用 flood fill 找到边缘像素的聚类,过滤掉太小的区域
  4. 梯度扩散:沿梯度方向在 180° 半圆内扩散,生成更平滑的分割蒙版(主要是为了深度图扩散,避免深度变化太快导致 threejs 展示效果不好)

最终会生成一个背景图和主体图,其中主体图中梯度过大的地方也会变成透明像素,这里主要是为了避免梯度过大的地方在 threejs 中渲染效果很不好看。 以及还有背景图对应的深度图,主体图对应的深度图(经过扩散处理过,避免边缘处梯度变化)。

这里因为考虑到背景修复比较慢,所以没有对图片进行多层分割,只按照两层来处理,所以像上面图里的腿的边缘出就会出现断裂的情况。

一个更好的方案可能是,按照灰度阈值将图片分隔为多层,灰度阈值采用能让梯度阈值像素点下降最多的阈值,这样可以获取多个灰度阈值,直到尽可能把图里的超过梯度阈值的像素点减少。

让一个图片分成更多层,展示效果更好。 (但是处理更麻烦,需要对多层图片做补全,对性能要求更高)

3. Inpainting 背景修复

前景被抠出来之后,背景上会留下一个”洞”。这里用了 LAMA(Large Mask Inpainting)模型来填补。

LAMA 是一个专门做图像修复的神经网络,效果也差强人意吧,只能说勉强能看。主要是能在笔记本上可以直接跑,虽然一张图可能也要跑几分钟…

如果有条件,用最新的 ai 补全的模型效果会更好。

4. 输出资源

最终每张图片会生成四个文件:

  • main.png — 前景图(带透明通道)
  • main-depth.png — 前景深度图
  • bg.png — 修复后的完整背景图
  • bg-depth.png — 背景深度图

如果切割更多层数,文件会更多。

再用 pngquant 做有损压缩,减小体积。

Three.js 3D 渲染

有了分层的图片和深度图,就可以用 Three.js 渲染了。

核心原理是 Displacement Mapping(位移贴图):把深度图作为位移贴图绑定到平面网格上,深度值会控制每个顶点沿法线方向的偏移量,让平面产生凹凸起伏。

const material = new THREE.MeshStandardMaterial({
  map: imageTexture,           // 彩色贴图
  displacementMap: depthTexture, // 深度图作为位移贴图
  displacementScale: 0.5,       // 位移强度
  transparent: true,
  alphaTest: 0.1,
  side: THREE.DoubleSide,
});

const geometry = new THREE.PlaneGeometry(
  planeWidth, planeHeight,
  512, 512  // 足够的细分段数才能体现位移效果
);

前景和背景各一个平面,叠在一起。由于前景的深度图数值更大(更亮),位移后自然就”浮”在背景前面了。转动视角时,两层之间就会产生视差,形成立体感。

在手机上,通过 DeviceOrientationEvent 获取陀螺仪数据,把手机的倾斜角度映射为 3D 场景中的旋转角度:

// 计算相对于初始位置的变化
const deltaBeta = event.beta - initial.beta;
const deltaGamma = event.gamma - initial.gamma;

// 映射到旋转角度,限制在 ±10 度内
targetRotation.rotationY =
  THREE.MathUtils.clamp(deltaGamma / maxTilt, -1, 1) * maxRotationRad;
targetRotation.rotationX =
  THREE.MathUtils.clamp(deltaBeta / maxTilt, -1, 1) * maxRotationRad;

为了让旋转手感更好,加了线性插值做平滑:

const newRotationX = current.rotationX +
  (target.rotationX - current.rotationX) * smoothing;

还做了一些细节处理:

  • 自动回中:手机静止 800ms 后,视角缓慢回到正中间
  • 呼吸动画:桌面端无操作 2 秒后,画面会轻微摇摆,像在”呼吸”
  • 按需渲染:只在有变化时才渲染,节省性能

性能优化

  • 像素比限制为 Math.min(devicePixelRatio, 2),避免高分屏过度渲染
  • 网格细分段数根据图片尺寸自适应,在 512~1024 之间
  • 启用视锥体剔除 frustumCulled = true
  • 使用 LinearFilter 代替 mipmap,减少纹理内存

最后

这个项目从想法到完成大概花了半个国庆假期,半年后再看,可能交给现在最新的模型,实现时间更短,实现效果也会更好。

批量生成了一些图,效果都还不错。可能像画面切腿的问题还需要靠分割更多层来解决。

本来是想搞个网页能支持手机上传自定义图片后预览全息效果,在手机跑这一套流程的。手机性能的话,可能图片修复需要用性能要求更低的算法,其他的应该没啥性能问题,但可能会有模型下载的问题,模型体积会比较大。

感觉太麻烦了遂作罢hhh

以及再扯一句,由普通图片生成裸眼 3d 的视差图片,也是一样的技术原理。

感谢看到这里~

点赞 0