Prompt & Done · DEPTHPOP pack
One photo, a 3D scene. Free, offline, on your CPU.
The model is Depth Anything V2 Small (HKU + TikTok researchers, Apache-2.0; the Base/Large models are CC-BY-NC, so use Small). It guesses how far away every pixel is. The script below turns that depth map into a looping 3D camera move.
1 Set up (once)
python3 -m venv pop && source pop/bin/activate
pip install onnxruntime numpy pillow opencv-python-headless
# ffmpeg must be on your PATH (macOS: brew install ffmpeg)The first run downloads the 99 MB ONNX model from onnx-community/depth-anything-v2-small next to the script.
2 Run it
python3 photo3d.py my_photo.jpg
# -> my_photo_depth.png (the depth map) and my_photo_3d.mp4 (a 5 s looping 3D move)Bright pixels in the depth map are near, dark pixels are far. Use the PNG for relief engraving or a 3D viewer, the MP4 for posting.
3 Our test (22 Oct 2026, Apple M4 Pro, CPU only)
| Job | Time |
|---|---|
| One photo (1080 px), depth map | 0.28 s |
| 14 photos, depth maps only (decode + save included) | 4.0 s |
| One photo, depth map + 5 s parallax video | about 12 s, mostly video encoding |
14 CC0 photos from Unsplash (mirrored on Wikimedia Commons). Your machine will differ.
4 photo3d.py
#!/usr/bin/env python3
"""photo3d.py: turn one photo into a depth map and a looping 3D-parallax video, offline, on the CPU.
Model: Depth Anything V2 Small (Apache-2.0), ONNX build from onnx-community/depth-anything-v2-small (99 MB).
Setup: pip install onnxruntime numpy pillow opencv-python-headless (and ffmpeg on your PATH)
Run: python3 photo3d.py my_photo.jpg -> my_photo_depth.png, my_photo_3d.mp4
"""
import math, os, subprocess, sys, time, urllib.request
import numpy as np, onnxruntime as ort, cv2
from PIL import Image
MODEL_URL = "https://huggingface.co/onnx-community/depth-anything-v2-small/resolve/main/onnx/model.onnx"
MODEL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "depth_anything_v2_small.onnx")
MEAN = np.array([0.485, 0.456, 0.406], np.float32); STD = np.array([0.229, 0.224, 0.225], np.float32)
def depth_map(sess, im):
W, H = im.size; s = 518
w, h = (int(round(W * s / H / 14)) * 14, s) if W >= H else (s, int(round(H * s / W / 14)) * 14)
x = (np.asarray(im.resize((w, h), Image.BICUBIC), np.float32) / 255. - MEAN) / STD
d = sess.run(None, {sess.get_inputs()[0].name: x.transpose(2, 0, 1)[None]})[0][0]
d = (d - d.min()) / (d.max() - d.min() + 1e-9)
return cv2.resize(d, (W, H), interpolation=cv2.INTER_CUBIC).clip(0, 1)
def parallax(img, depth, n=150, amp=0.035, zoom=0.05, fps=30):
"""Camera drifts in a small circle: near pixels move more than far ones."""
H, W = depth.shape
d = cv2.GaussianBlur(cv2.dilate(depth, np.ones((5, 5), np.uint8)), (0, 0), 1.2) # soften edges, less tearing
gx, gy = np.meshgrid(np.arange(W, dtype=np.float32), np.arange(H, dtype=np.float32))
for i in range(n):
a = 2 * math.pi * i / n
sx, sy = math.cos(a) * amp * W, math.sin(a) * amp * W * 0.6
z = zoom * (0.5 - 0.5 * math.cos(a)) # slow push-in and back
f = d - 0.5
mx = gx + sx * f + (gx - W / 2) * z * (0.2 + f)
my = gy + sy * f + (gy - H / 2) * z * (0.2 + f)
yield cv2.remap(img, mx.astype(np.float32), my.astype(np.float32), cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
def main(path):
if not os.path.exists(MODEL):
print("downloading the model (99 MB, once)..."); urllib.request.urlretrieve(MODEL_URL, MODEL)
sess = ort.InferenceSession(MODEL, providers=["CPUExecutionProvider"])
im = Image.open(path).convert("RGB"); t = time.time(); depth = depth_map(sess, im)
print("depth map: %.2f s" % (time.time() - t))
base = os.path.splitext(path)[0]
Image.fromarray((depth * 255).astype(np.uint8)).save(base + "_depth.png")
img = cv2.cvtColor(np.asarray(im), cv2.COLOR_RGB2BGR); H, W = depth.shape
p = subprocess.Popen(["ffmpeg", "-y", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "bgr24", "-s", f"{W//2*2}x{H//2*2}", "-r", "30",
"-i", "-", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "16", base + "_3d.mp4"], stdin=subprocess.PIPE)
for fr in parallax(img, depth): p.stdin.write(np.ascontiguousarray(fr[:H//2*2, :W//2*2]).tobytes())
p.stdin.close(); p.wait(); print("wrote", base + "_depth.png", base + "_3d.mp4")
if __name__ == "__main__":
main(sys.argv[1])
5 Fair to say
The depth is relative, not metric, and it is one view of the scene: hidden areas are guessed, so keep the camera move small (a few degrees). Thin wires and glass are approximate. This makes a 2.5D scene from a photo, not a full 3D model.