
一、开场白:为什么 2D→3D 值得被认真对待
2024 年苹果把 Apple Vision Pro 塞进了开发者手里,2025 年起各种 3D 显示器、裸眼 3D 手机屏也陆续铺开。但一个尴尬的事实摆在那:内容端完全跟不上硬件端。人类过去一百年攒下的视频几乎全是 2D 单目拍摄的,指望好莱坞重新拍一遍 3D 版不现实------所以"把任意 2D 视频转换成可观看的立体 3D 视频"就成了一个既有学术价值、又有商业想象力的问题。
这活儿听起来简单:给左眼画面补一个右眼画面不就完了?但难点在于------右眼画面不是"画"出来的,是"算"出来的。它必须满足两个硬约束:
- 几何一致:左右眼画面的视差(disparity)必须符合真实三维场景的几何关系,否则大脑立刻罢工,出现眩晕;
- 时间一致:每一帧的立体关系不能跳变,否则看几秒就头晕想吐。
传统方法(比如直接 CNN 生成右视图)在这两点上都不太行。腾讯 ARC Lab 的 StereoCrafter 给出的答案很有意思:不跟几何死磕,而是"先用几何算个八九不离十,再用扩散模型把漏洞补上"------一个经典的"粗到细"(coarse-to-fine)工程哲学。
本篇文章就从源码出发,把它扒个底朝天。
二、项目速览
| 项目 | 信息 |
|---|---|
| 名称 | StereoCrafter |
| 团队 | Tencent AI Lab / ARC Lab(赵思杰、胡文波、寸晓东、张勇、李晓宇等) |
| 论文 | arXiv:2409.07447(2024.09 提交技术报告) |
| 项目主页 | stereocrafter.github.io |
| 代码 | GitHub: TencentARC/StereoCrafter |
| 模型权重 | HuggingFace: TencentARC/StereoCrafter |
| 首版开源 | 2024.12.27(推理代码 + 权重) |
| 核心卖点 | 任意 2D 视频 → 立体 3D,支持 3D 眼镜 / Apple Vision Pro / 3D 显示器 |
| 分支情况 | main(SVD 版);v2(2026.05 更新,换成 Wan2.1-VACE-14B 骨干) |
一句话概括整个系统:输入一段单目视频,输出一段可以和原视频拼成左右立体对的"右眼视频",附带遮挡掩码和可视化深度图。
整个框架分两条腿走路:
- Stage 1:Depth-based Video Splatting ------ 用 DepthCrafter 估计视频深度,再把左视图按视差"泼"(splat)到右视图的位置,同时算出遮挡掩码;
- Stage 2:Stereo Video Inpainting ------ 把 splatting 出的残缺右视图 + 遮挡掩码喂给一个微调过的 Stable Video Diffusion(SVD),把空洞补全,生成高保真右眼视频。
两个阶段都是开箱即用的推理脚本,不需要任何训练。
三、源码下载:带着"附件"的克隆
这个项目不是孤零零一个仓库,它依赖两个子模块(git submodule),所以官方要求用 --recursive 克隆:
bash
git clone --recursive https://github.com/TencentARC/StereoCrafter
cd StereoCrafter
看一下根目录的 .gitmodules,两个"附件"赫然在列:
ini
[submodule "dependency/DepthCrafter"]
path = dependency/DepthCrafter
url = https://github.com/Tencent/DepthCrafter
[submodule "dependency/Forward-Warp"]
path = dependency/Forward-Warp
url = https://github.com/wbhu/Forward-Warp.git
dependency/DepthCrafter:腾讯自家的视频深度估计模型(也是 SOTA 级别);dependency/Forward-Warp:wbhu(论文作者胡文波)定制的前向 warp 算子,做 splatting 用,需要编译 C++/CUDA 扩展。
踩坑点 1 :如果你忘了
--recursive,dependency/下会是两个空目录。补救命令:git submodule update --init --recursive。
克隆完之后的目录结构长这样(我按实际仓库整理):
bash
StereoCrafter/
├── .gitmodules # 子模块声明
├── README.md # 使用说明
├── License-Code.txt # 代码许可证
├── requirements.txt # Python 依赖
├── run_inference.sh # 一键推理脚本
├── depth_splatting_inference.py # Stage 1:深度估计 + splatting
├── inpainting_inference.py # Stage 2:立体视频 inpainting
├── assets/ # 展示图
├── source_video/ # 示例视频(camel.mp4)
├── pipelines/
│ └── stereo_video_inpainting.py # 核心 pipeline 类(自定义 SVD inpainting 管线)
├── dependency/ # 子模块
│ ├── DepthCrafter/ # 视频深度估计
│ └── Forward-Warp/ # 前向 splatting 算子(需编译)
└── weights/ # (自建)存放三个模型权重
整个项目只有 3 个核心 Python 文件 + 1 个脚本 + 1 个子模块算子,代码量不大,但每一行都值得细品------这正是它适合做源码拆解的原因。
四、环境搭建:Python 3.8 + CUDA 11.8
官方声明在 Python 3.8 + CUDA 11.8 上运行。requirements.txt 内容如下:
ini
torch==2.0.1
torchvision==0.15.2
fire==0.5.0
accelerate==1.0.1
safetensors==0.4.3
transformers==4.42.3
decord==0.6.0
diffusers==0.29.2
xformers==0.0.20
av==12.3.0
matplotlib==3.7.5
mediapy==1.2.2
opencv-python==4.10.0.84
安装命令:
bash
pip install -r requirements.txt
然后是唯一需要"手动编译"的部件------Forward-Warp:
bash
cd ./dependency/Forward-Warp
chmod a+x install.sh
./install.sh
踩坑点 2(CUDA 12 用户注意) :官方 commit 历史里有一个
Update inpainting_inference.py change the order to avoid segmentation fault in Cuda12的修复------说明作者在 CUDA 12 环境下遇到过段错误,通过调整代码执行顺序规避。如果你用 CUDA 12 + 新版本 torch,建议直接切到v2分支(见文末),v2 官方支持 Python 3.13 + CUDA 12.8。
踩坑点 3(xformers) :xformers==0.0.20需要和 torch 2.0.1 严格配对。装不上就检查你的 CUDA 版本和 wheel 来源,xformers 是出了名的"版本洁癖"。
踩坑点 4(decord + opencv) :decord==0.6.0对视频解码格式挑剔,如果读视频报错可以试试pip install --no-cache-dir decord;OpenCV 和 decord 都要能读你的输入视频编码,建议先用 H.264 的 mp4。
五、模型权重下载:三巨头缺一不可
在项目根目录建 weights/,然后依次拉取三个 HuggingFace 仓库(都是 git-lfs 托管的大文件):
bash
mkdir weights && cd ./weights
git lfs install
# 1. SVD img2vid(提供 CLIP 图像编码器 + VAE)
git clone https://huggingface.co/stabilityai/stable-video-diffusion-img2vid-xt-1-1
# 2. DepthCrafter(视频深度估计)
git clone https://huggingface.co/tencent/DepthCrafter
# 3. StereoCrafter 自家权重(微调后的 UNet)
git clone https://huggingface.co/TencentARC/StereoCrafter
三个权重各司其职:
| 权重 | 提供什么 | 被谁使用 |
|---|---|---|
| SVD img2vid | CLIP image_encoder、VAE、scheduler、基础配置 | Stage 1 的 DepthCrafter pipeline 组件 + Stage 2 的骨架 |
| DepthCrafter | 视频深度估计 UNet(DiffusersUNetSpatioTemporalConditionModelDepthCrafter) | Stage 1 深度估计 |
| StereoCrafter | 微调后的 UNet(unet_diffusers 子目录) |
Stage 2 立体 inpainting |
踩坑点 5(磁盘空间) :三个仓库加起来十几个 GB。国内直连 HuggingFace 慢的话,可以用
hf-mirror.com镜像:git clone https://hf-mirror.com/stabilityai/stable-video-diffusion-img2vid-xt-1-1。
六、项目运行:一条命令,两段剧情
官方一键脚本 run_inference.sh 长这样:
bash
# Stage 1(默认被注释掉,因为通常先跑一次即可)
# python depth_splatting_inference.py \
# --pre_trained_path ./weights/stable-video-diffusion-img2vid-xt-1-1 \
# --unet_path ./weights/DepthCrafter \
# --input_video_path ./source_video/camel.mp4 \
# --output_video_path ./outputs/camel_splatting_results.mp4
# Stage 2
python inpainting_inference.py \
--pre_trained_path ./weights/stable-video-diffusion-img2vid-xt-1-1 \
--unet_path ./weights/StereoCrafter \
--input_video_path ./outputs/camel_splatting_results.mp4 \
--save_dir ./outputs \
--tile_num 2
注意顺序:先跑 Stage 1 得到 splatting 结果视频,再把它喂给 Stage 2。Stage 1 输出的是 2×2 宫格视频(左上原图、右上深度可视化、左下遮挡掩码、右下 splatting 右视图),Stage 2 会解析这个宫格,取出 warped 帧和 mask,生成最终的 side-by-side 立体视频 + 红蓝 anaglyph 视频。
两个脚本都用了 Fire(main)------Google 的 Fire 库,把 main() 的参数自动变成命令行参数,所以你可以直接传参覆盖默认值,比如调 --max_disp 控制视差强度。
七、源码调试拆解(Stage 1):depth_splatting_inference.py
这个文件是整条流水线的第一步,逻辑链是:读视频 → 调 DepthCrafter 估计深度 → 深度转视差 → 前向 splatting 生成右视图 + 遮挡掩码。我们逐块拆。
7.1 读视频:read_video_frames
python
def read_video_frames(video_path, process_length, target_fps, max_res, dataset="open"):
vid = VideoReader(video_path, ctx=cpu(0))
original_height, original_width = vid.get_batch([0]).shape[1:3]
height = round(original_height / 64) * 64
width = round(original_width / 64) * 64
if max(height, width) > max_res:
scale = max_res / max(original_height, original_width)
height = round(original_height * scale / 64) * 64
width = round(original_width * scale / 64) * 64
...
fps = vid.get_avg_fps() if target_fps == -1 else target_fps
stride = round(vid.get_avg_fps() / fps)
frames_idx = list(range(0, len(vid), stride))
...
frames = vid.get_batch(frames_idx).asnumpy().astype("float32") / 255.0
return frames, fps, original_height, original_width
几个关键设计:
- 64 对齐:高度宽度都向上取整到 64 的倍数------这是为了兼容 VAE 的 8× 下采样和 UNet 的时空注意力要求;
- max_res 限幅:默认 1024,超出则等比缩放,防止深度估计阶段爆显存;
- stride 采样:通过抽帧把帧率降下来,控制推理帧数(DepthCrafter 每窗口只处理 70 帧,overlap 25 帧)。
7.2 深度估计:DepthCrafterDemo
python
class DepthCrafterDemo:
def __init__(self, unet_path, pre_trained_path, cpu_offload="model"):
unet = DiffusersUNetSpatioTemporalConditionModelDepthCrafter.from_pretrained(
unet_path, low_cpu_mem_usage=True, torch_dtype=torch.float16)
self.pipe = DepthCrafterPipeline.from_pretrained(
pre_trained_path, unet=unet, torch_dtype=torch.float16, variant="fp16")
if cpu_offload is not None:
if cpu_offload == "sequential":
self.pipe.enable_sequential_cpu_offload()
elif cpu_offload == "model":
self.pipe.enable_model_cpu_offload()
...
try:
self.pipe.enable_xformers_memory_efficient_attention()
except Exception as e:
print(e); print("Xformers is not enabled")
self.pipe.enable_attention_slicing()
架构洞察 :DepthCrafter 的 pipeline 直接复用了 SVD 的 VAE 和 scheduler------这就是为什么 Stage 1 的 --pre_trained_path 指向 SVD 权重。DepthCrafter = SVD 骨架 + 自定义时空条件 UNet + 深度输出头,和 StereoCrafter 微调 UNet 是同一套"借壳上市"玩法。
infer() 里值得注意的细节:
python
with torch.inference_mode():
res = self.pipe(frames, height=frames.shape[1], width=frames.shape[2],
output_type="np", guidance_scale=1.2,
num_inference_steps=8, window_size=70, overlap=25).frames[0]
# 三通道深度图 → 单通道
res = res.sum(-1) / res.shape[-1]
# 上采样回原始分辨率
tensor_res = torch.tensor(res).unsqueeze(1).float().contiguous().cuda()
res = F.interpolate(tensor_res, size=(original_height, original_width),
mode='bilinear', align_corners=False)
res = res.cpu().numpy()[:, 0, :, :]
# 全视频归一化到 [0, 1]
res = (res - res.min()) / (res.max() - res.min())
- 8 步去噪 +
guidance_scale=1.2:Diffusion 深度估计用极少步数就够了,这是工程上的精打细算; - 深度归一化是"整段视频级" :
res.min()/res.max()取的是全片极值,保证深度在时间轴上可比------这直接决定了后续视差转换的一致性; - 可选
save_depth把深度存成.npz和可视化 mp4,方便调试。
7.3 前向 splatting 核心:ForwardWarpStereo
这是全项目最有技术含量的一个类,务必逐行看:
python
class ForwardWarpStereo(nn.Module):
def __init__(self, eps=1e-6, occlu_map=False):
super().__init__()
self.eps = eps
self.occlu_map = occlu_map
self.fw = forward_warp()
def forward(self, im, disp):
im = im.contiguous()
disp = disp.contiguous()
weights_map = disp - disp.min()
weights_map = 1.414 ** weights_map # 用 1.414 代替 exp,防数值上溢
flow = -disp.squeeze(1)
dummy_flow = torch.zeros_like(flow, requires_grad=False)
flow = torch.stack((flow, dummy_flow), dim=-1) # 只水平方向移动
res_accum = self.fw(im * weights_map, flow) # 加权像素累加
mask = self.fw(weights_map, flow)
mask.clamp_(min=self.eps)
res = res_accum / mask # 归一化
if not self.occlu_map:
return res
else:
ones = torch.ones_like(disp, requires_grad=False)
occlu_map = self.fw(ones, flow)
occlu_map.clamp_(0.0, 1.0)
occlu_map = 1.0 - occlu_map # 1 = 遮挡
return res, occlu_map
拆解它的数学:
为什么用前向 warp(forward mapping)而不是后向 warp(backward warping)? 因为视差场定义的是"左视图像素 → 右视图像素"的映射方向。若用后向 warp 做 grid_sample,每个右视图像素去左视图"取值",在遮挡区域取到的可能是背景像素,语义就错了。前向 splatting 把每个左视图像素"投"到右视图,由深度决定权重,冲突时加权求和,这才是符合物理的。
权重公式:
ini
w = 1.414^disp'
其中 disp' = disp - disp.min()(相对视差,避免负指数)。论文原文用的是 w = 2^disp,代码里换成 1.414 ≈ √2,注释写得很直白:"using 1.414 instead of EXP for avoiding numerical overflow" 。含义是一样的:视差越大(离相机越近)的像素,splat 时权重越大------近处物体在遮挡关系中拥有更高优先级,这是前向 splatting 保持前景正确的关键。
归一化 :res_accum = Σ(w_i · im_i),mask = Σ w_i,res = res_accum / mask------本质是加权平均,可微(注释里特意强调 detach will lead to unconverge!!,这是为训练留的后门,推理时无所谓)。
遮挡掩码 :把全 1 张量也 splat 一遍,被"覆盖"过的位置接近 1,没被任何像素光顾的位置为 0,occlu_map = 1 - 覆盖度,于是 1 表示空洞(需要修复),0 表示有效 。方向(flow)是 (-disp, 0)------右视图在左视图左边,所以视差取负、水平移动,垂直方向是 0(双目相机共面时只有水平视差,这是标准双目几何假设)。
7.4 组装输出:DepthSplatting
python
disp_map = disp_map * 2.0 - 1.0 # [0,1] → [-1,1]
disp_map = disp_map * max_disp # × 最大视差(默认 20 像素)
with torch.no_grad():
right_video, occlusion_mask = stereo_projector(left_video, disp_map)
- 深度
[0,1]映射到[-max_disp, +max_disp]:max_disp控制立体感的强度,20 像素是默认值; - 按 batch(默认 10 帧)分批跑,处理完
del + empty_cache() + gc.collect()三连释放显存; - 输出用
cv2.VideoWriter(mp4v 编码)写成 2×2 宫格:左上原图 / 右上深度可视化 / 左下遮挡掩码 / 右下 splatting 右视图------这个宫格就是 Stage 2 的"输入素材"。
八、源码调试拆解(Stage 2):inpainting_inference.py
Stage 2 的任务:把残缺的 splatting 右视图 + 遮挡掩码喂给扩散模型,补全空洞,输出高保真右眼视频。这个文件里藏着三个有意思的设计。
8.1 解析宫格视频
python
frames = torch.tensor(frames.asnumpy()).permute(0, 3, 1, 2).float()
height, width = frames.shape[2] // 2, frames.shape[3] // 2
frames_left = frames[:, :, :height, :width] # 左上:原左视图
frames_mask = frames[:, :, height:, :width] # 左下:遮挡掩码
frames_warpped = frames[:, :, height:, width:] # 右下:splatting 右视图
frames = torch.cat([frames_warpped, frames_left, frames_mask], dim=0)
Stage 1 的宫格在这里被"拆包":取出 warped 帧、左视图、mask,然后沿着时间维拼接 成一个 3 倍帧数的张量------这不是为了炫技,而是复用 SVD 原生的"多帧条件"机制,把三种输入作为一个视频序列喂进去。注意 mask 是多通道 RGB,后面会做 mean(dim=1, keepdim=True) 压成单通道。
8.2 自回归式分块:让任意长度视频保持时间一致
python
for i in range(0, num_frames, frames_chunk - overlap):
...
if generated is not None:
input_frames_i[:cur_overlap] = generated[-cur_overlap:] # 关键!
...
video_latents = spatial_tiled_process(input_frames_i, mask_frames_i, pipeline, tile_num, ...)
generated = torch.stack(video_frames)
if i != 0:
generated = generated[cur_overlap:]
results.append(generated)
这是全文最重要的工程细节之一 :SVD 一次只能生成 14/25 帧,而视频可能有几百帧。朴素做法是每 23 帧切一块独立生成再拼接------但相邻块接缝处会出现修复区域不一致(同一处遮挡,前块补成 A,后块补成 B)。
StereoCrafter 的解法是 自回归 + overlap:
- 块大小
frames_chunk=23,步长frames_chunk - overlap = 20,即相邻块有 3 帧重叠; - 当前块生成完后,把本块生成的最后
overlap帧直接覆盖到下一块的输入开头------模型看到"上一段我补的"长什么样,就会顺着往下补,保证接缝处时间连续。
这在论文里叫 auto-regressive modeling ,训练时对应"随机把前 n 帧换成 GT"的策略。代码里对边界情况的处理(cur_i、cur_overlap 的重新计算)相当细致,值得反复读两遍。
8.3 Tiled 扩散:高分辨率视频的显存逃生舱
python
def spatial_tiled_process(cond_frames, mask_frames, process_func, tile_num,
spatial_n_compress=8, **kargs):
tile_overlap = (128, 128)
tile_size = (int((height + tile_overlap[0] * (tile_num - 1)) / tile_num),
int((width + tile_overlap[1] * (tile_num - 1)) / tile_num))
tile_stride = (tile_size[0] - tile_overlap[0], tile_size[1] - tile_overlap[1])
for i in range(tile_num):
for j in range(tile_num):
cond_tile = cond_frames[:, :, i*stride0 : i*stride0+ts0, j*stride1 : j*stride1+ts1]
mask_tile = mask_frames[..., 同款切片]
tile = process_func(frames=cond_tile, frames_mask=mask_tile, ...,
output_type="latent").frames[0]
rows.append(tile)
...
逻辑:把高分辨率视频沿空间切成 tile_num × tile_num 个块(每块之间留 128 像素重叠),各自在潜空间做完整去噪,然后在潜空间(latent,空间尺寸是像素的 1/8)对重叠区做线性混合:
python
# blend_h / blend_v:重叠区按线性权重混合
weight_b = torch.arange(overlap_size).view(1, 1, 1, -1) / overlap_size
b[:, :, :, :overlap_size] = (1 - weight_b) * a[:, :, :, -overlap_size:] \
+ weight_b * b[:, :, :, :overlap_size]
tile_overlap = 128像素 → 潜空间正好 16 个格子(spatial_n_compress=8);- 混合在潜空间做,避免像素域混合带来的块边界生硬;
- 2K 输入用
--tile_num 2甚至更大,官方说tile_num是"宽高两个维度的块数",即tile_num=2是 2×2 四块。
论文里有个很有说服力的消融:512×960 可以直接全局推理;1024×1920 不开 tiled 直接 OOM,开了 tiled 就能在同样显存下跑出更精细的结果。
8.4 输出合成:Side-by-Side 与红蓝 Anaglyph
python
frames_sbs = torch.cat([frames_left, frames_output], dim=3) # 左右并排
# anaglyph:左视图只留红色通道,右视图只留青(绿+蓝)通道
vid_left[:, :, :, 1] = 0
vid_left[:, :, :, 2] = 0
vid_right[:, :, :, 0] = 0
vid_anaglyph = vid_left + vid_right
红蓝 3D 就是最朴素的通道分离:左眼画面只保留 R 通道,右眼画面只保留 G+B 通道,叠加后戴红蓝眼镜即可分离。SBS(side-by-side)则直接交给 3D 显示器/VR 设备去处理。
九、核心 Pipeline 拆解:pipelines/stereo_video_inpainting.py
这个文件定义了一个继承自 DiffusionPipeline 的 StableVideoDiffusionInpaintingPipeline ,是 Stage 2 的灵魂。它把 SVD 原版 pipeline 改造成"视频 inpainting"模式,核心改动集中在 __call__ 里。
9.1 与官方 SVD pipeline 的差异
| 环节 | 官方 SVD img2vid | StereoCrafter 版 |
|---|---|---|
| 条件 | 单张首帧图 | 整段 warped 视频帧(每 5 帧一批编码) |
| UNet 输入通道 | 8(噪声 4 + 条件 latent 4) | 9(噪声 4 + 条件 4 + mask 1) |
| mask 处理 | 无 | _encode_mask_frames 下采样到潜空间尺寸 |
| 首帧条件 | image_embeddings(CLIP) | 同样保留 CLIP 条件(取 frames[0:1]) |
9.2 __call__ 主流程逐段解读
python
# 3. 编码 CLIP 条件(取视频第一帧)
image_embeddings = self._encode_image(frames[0:1], device, num_videos_per_prompt, do_cfg)
# 注意:SVD 训练时用 fps-1 做微条件
fps = fps - 1
# 4. VAE 编码条件帧(分批,每 5 帧一编,省显存)
frame_latents = self._encode_vae_frames(frames, device, ...)
mask_latents = self._encode_mask_frames(frames_mask, device, ...)
# 8. 去噪循环
latent_model_input = torch.cat([latents] * 2) if do_cfg else latents
latent_model_input = torch.cat([latent_model_input, frame_latents, mask_latents], dim=2)
noise_pred = self.unet(latent_model_input, t,
encoder_hidden_states=image_embeddings,
added_time_ids=added_time_ids)[0]
# CFG:uncond + guidance_scale * (cond - uncond)
noise_pred_uncond, noise_pred_cond = noise_pred.chunk(2)
noise_pred = noise_pred_uncond + self.guidance_scale * (noise_pred_cond - noise_pred_uncond)
latents = self.scheduler.step(noise_pred, t, latents).prev_sample
关键洞察 1:通道拼接式条件注入 。SVD 的 UNet 输入是 [噪声latent, 条件帧latent] 的 8 通道拼接;这里把 mask 也压成 1 通道拼进来变成 9 通道,并且论文说新通道的首层卷积权重零初始化------这样微调启动时模型行为和原版一致,训练更稳。这就是"零初始化"技巧在视频 inpainting 上的应用。
关键洞察 2:CFG 的 guidance 是逐帧的。
python
guidance_scale = torch.linspace(min_guidance_scale, max_guidance_scale, num_frames).unsqueeze(0)
推理参数里 min_guidance_scale=1.01, max_guidance_scale=1.01------guidance 恒为 1.01,几乎等于不做 CFG。为什么?因为 inpainting 任务里"条件"是整段视频和 mask,信息量极大,模型已经被条件牢牢约束住了,过强的 CFG 反而会引入伪影、破坏与左视图的几何一致。这个 1.01 的选择说明作者做了充分的调参。
关键洞察 3:added_time_ids = [fps, motion_bucket_id, noise_aug_strength] 。这是 SVD 的三大微条件:fps 控制运动节奏感、motion_bucket_id 控制运动幅度、noise_aug_strength 控制输入退化程度。推理时固定 fps=7, motion_bucket_id=127, noise_aug_strength=0.0------输入帧是无噪的干净图,所以 noise_aug 为 0。
9.3 内存管理三件套
python
# 1) VAE 分批编码条件帧
def _encode_vae_frames(self, frames, ..., n_frames_per_time=5):
for i in range(0, frames.shape[0], n_frames_per_time):
frame_latent = self.vae.encode(frames[i:i+n_frames_per_time]).latent_dist.mode()
latent_list.append(frame_latent)
frame_latents = torch.cat(latent_list, dim=0).unsqueeze(0)
# 2) decode_latents 分批解码
for i in range(0, latents.shape[0], decode_chunk_size):
frame = self.vae.decode(latents[i:i+decode_chunk_size], **decode_kwargs).sample
# 3) fp16 陷阱处理
needs_upcasting = self.vae.dtype == torch.float16 and self.vae.config.force_upcast
if needs_upcasting:
self.vae.to(dtype=torch.float32)
# ... 编码完再转回 fp16
AutoencoderKLTemporalDecoder 是 SVD 的时空 VAE,它在 fp16 下 force_upcast------必须临时升到 fp32 编码,否则精度崩,用完再转回来。这是跑 SVD 系列必踩的坑,官方 pipeline 同样处理了。
9.4 一个值得吐槽的细节
python
# 在 inpainting_inference.py 里
video_latents = video_latents.unsqueeze(0)
if video_latents == torch.float16: # ← 这行恒为 False
pipeline.vae.to(dtype=torch.float16)
video_latents 是个 tensor,tensor == torch.float16 比较的是值而不是 dtype------这行判断永远不成立,是个潜伏的 bug(死代码) 。它不致命,因为 VAE 本来就在 fp16(且 force_upcast 路径已经处理过),但如果你是强迫症,这就是个可以提 PR 的点。源码拆解的意义就在这:不仅要看懂它做了什么,也要看懂它没做什么。
十、文件关系梳理:一张图看懂数据流
整个项目的信息流可以浓缩成下面这条链:
ini
source_video/camel.mp4
│ (单目左视图视频)
▼
┌─────────────────────────────────────────────────────────┐
│ depth_splatting_inference.py 【Stage 1】 │
│ │
│ read_video_frames ──► DepthCrafterDemo.infer │
│ │ │ (SVD组件+Depth UNet) │
│ │ ▼ │
│ │ 视频深度 depth [T,H,W]∈[0,1] │
│ │ │ │
│ │ ▼ │
│ │ ForwardWarpStereo (Forward-Warp 算子) │
│ │ disp = (depth*2-1) * max_disp │
│ │ right_video, occlusion_mask ──┐ │
│ ▼ ▼ │
│ 2×2 宫格视频: [原图|深度图|掩码|splatting右视图] │
└──────────────┬──────────────────────────────────────────┘
│ outputs/camel_splatting_results.mp4
▼
┌─────────────────────────────────────────────────────────┐
│ inpainting_inference.py 【Stage 2】 │
│ │
│ 拆包宫格: warped帧 + 左视图 + mask │
│ │ │
│ ▼ │
│ 自回归分块(23帧, overlap 3帧) │
│ │ │
│ ▼ │
│ spatial_tiled_process (tile_num×tile_num 潜空间分块) │
│ │ │
│ ▼ │
│ StableVideoDiffusionInpaintingPipeline.__call__ │
│ UNet输入 = [噪声|warped帧latent|mask latent] (9通道) │
│ 去噪 8 步 ──► decode_latents ──► 补全后的右眼视频 │
└──────────────┬──────────────────────────────────────────┘
▼
outputs/camel_inpainting_results_sbs.mp4(左右并排)
outputs/camel_inpainting_results_anaglyph.mp4(红蓝)
依赖关系:
depth_splatting_inference.py依赖:dependency/DepthCrafter(pipeline、UNet、可视化工具)、Forward_Warp(编译产物)、SVD 权重、DepthCrafter 权重;inpainting_inference.py依赖:pipelines/stereo_video_inpainting.py(自定义 pipeline 类)、SVD 权重(CLIP 编码器 + VAE + scheduler)、StereoCrafter 微调权重(UNet);- 两个脚本共享 SVD 权重作为底座------这正是这套"借 SVD 骨架"设计的经济之处:一份底座,两处复用。
十一、原理分析:把公式摊开讲
11.1 深度 → 视差:立体几何的约定
双目系统中,深度与视差近似成反比(平行相机模型):
ini
d = f · B / Z
其中 f 是焦距,B 是基线(两眼间距),Z 是深度,d 是视差(像素)。DepthCrafter 输出的是逆深度/视差语义的深度图(近处亮、远处暗),所以代码里:
ini
disp = (depth * 2 - 1) * max_disp
把 [0,1] 深度映射到 [-max_disp, +max_disp] 的视差范围。max_disp=20 意味着最远的点视差 -20px(在左视图左边 20px 处出现),最近的 +20px------这个范围控制立体强度,调太大容易眩晕,调太小没有立体感。
11.2 前向 Splatting 的加权融合
右视图每个像素可能收到多个左视图像素的"投票"(前景遮挡背景时尤其如此)。StereoCrafter 用深度加权解决冲突:
scss
I_R(p) = Σ_q w(q) · I_L(q) / Σ_q w(q), q 满足 p = q + (d_q, 0)
w(q) = 1.414^(d_q - d_min)
- 分子分母同步 splat,得到归一化颜色(对应
res_accum / mask); - 权重随视差指数增长:近处物体权重高,在前景/背景冲突时胜出,保证"近处盖远处"的正确层叠顺序;
- 选
1.414 ≈ √2而非e,是工程上的数值稳定妥协。
11.3 遮挡掩码的生成
scss
occ(p) = 1 - FW(ones)(p)
把全 1 张量按同样的 flow splat,得到"每个像素被多少源像素覆盖"的图,1 - 覆盖度 就是遮挡掩码:没被任何像素光顾的 = 遮挡空洞 = 1。这些空洞正是扩散模型要补的区域。
11.4 扩散 inpainting 的条件注入
Stage 2 本质是条件扩散生成:在每步去噪时,UNet 输入是
c
[ z_t (噪声latent) ‖ E_vae(V_warped) ‖ E_mask(M) ] ← 9 通道
条件(warped 帧 + mask)在潜空间与噪声对齐拼接,模型学习的是 P(V_right | V_warped, M)。零初始化保证微调初期模型输出 ≈ 原版 SVD,然后逐步"学会"把空洞按真实右视图的分布补上。扩散模型的优势是生成的不是"平均模糊解",而是有纹理、有细节的合理猜测------这正是它吊打传统 inpainting(FuseFormer/E2FGVI)的地方。
11.5 训练配方(论文 Section 4,代码未开源训练部分)
- 数据:自建 18 万序列 / 2500 万帧立体视频数据集,经 PySceneDetect 切镜头 → 视差对齐 → Match-Stereo-Videos 匹配 → splatting 生成三元组
(V_warped, M, V_right)→ PSNR > 25dB 过滤; - 数据规模:25 × 576 × 1024,帧步长 1~6;
- 优化:AdamW,lr=1e-5,8×A100,每卡 batch=1,26K 迭代,DeepSpeed Stage 2 + 梯度检查点 + fp16;
- 自回归训练:随机把前 n 帧替换为 GT,n∈0,N------让模型学会"接着上一段继续补"。
十二、效果对比:赢在哪、怎么赢的
12.1 2D→3D 转换:vs Deep3D / Owl3D / Immersity AI
| 方法 | 右视图质量 | 与左视图几何一致性 | 时间一致性 |
|---|---|---|---|
| Deep3D | 整体尚可 | 差(立体匹配结果混乱) | 一般 |
| Owl3D | 较好 | 较好但有伪影(扶手断裂等) | 一般 |
| Immersity AI | 较好 | 较好但有伪影 | 一般 |
| StereoCrafter | 高保真 | 强(warped 帧直接约束) | 强(自回归 + overlap) |
Deep3D 是端到端 CNN 硬学"左图→右图",缺乏几何约束,生成的右视图在立体匹配下与左视图对不上;商业软件 Owl3D / Immersity 有约束但修复伪影明显。StereoCrafter 的"几何先算 + 扩散精修"组合拳在两者之间取得了平衡------非遮挡区域与左视图像素级一致(因为就是 warp 出来的),遮挡区域又有扩散模型生成细节。
12.2 视频 Inpainting:vs FuseFormer / E2FGVI / ProPainter
| 方法 | 遮挡区修复 | 非遮挡区保真 |
|---|---|---|
| FuseFormer | 模糊 | 严重质量退化 |
| E2FGVI | 模糊 | 严重质量退化 |
| ProPainter | 模糊 | 一般 |
| StereoCrafter | 细节丰富 | 与 warp 结果高度一致 |
传统 inpainting 是"填充"逻辑,倾向于生成平滑模糊的中间值;StereoCrafter 是"生成"逻辑,扩散模型给出符合场景先验的细节。而且注意一个容易被忽略的点:传统方法会把非遮挡区域也改坏(因为它拿不到"哪些区域不用动"的先验),StereoCrafter 因为 mask 只在遮挡处引导,非遮挡区域基本保留 warp 结果,保真度甩开一个身位。
12.3 消融实验的结论
- 去掉 overlap(自回归) → 相邻块修复区时间不一致,出现闪烁;
- 高分辨率不开 tiled → 直接 OOM;开了 tiled → 同显存下出更精细结果;
- 深度估计器对比:DepthCrafter(视频深度)> Depth Anything V2(单帧深度),因为前者时间一致性更好------单帧深度在视频上跳变是立体视频的大忌。
十三、踩坑点大合集
把全文散落的坑汇总一下,按出现顺序排:
- 忘记
--recursive→dependency/空目录,运行报 ModuleNotFoundError。补救:git submodule update --init --recursive。 - Forward-Warp 编译失败 → 需要匹配的 CUDA 工具链;Windows 用户尤其痛苦(官方
install.sh是 Linux 脚本),建议 WSL2 或 Docker。 - xformers 与 torch 版本错配 → 老老实实用
torch==2.0.1 + xformers==0.0.20组合;装不上就先跳过,代码有 try/except 兜底(会打印 "Xformers is not enabled")。 - CUDA 12 段错误 → 作者自己踩过(有修复 commit),建议直接切 v2 分支。
- 显存不足 → 三板斧:
cpu_offload(model / sequential)、减小process_length和max_res、增大tile_num。 max_disp调太大 → 立体感强但边缘空洞多、易眩晕;调太小 → 立体感弱。默认 20 是平衡点。- HuggingFace 下载慢 → 换
hf-mirror.com镜像。 - 死代码观察 :
if video_latents == torch.float16恒 False,不影响运行但说明代码仓促。
十四、局限性 & v2 分支:故事还在继续
14.1 论文自曝的局限
- 深度估计仍是天花板:高运动、雾、火等复杂场景下深度精度和一致性不足,直接影响 splatting 质量;
- 不支持实时:目前是离线推理,距离"直播 2D→3D"还有距离;
- 隐性成本:训练依赖自建的大规模立体数据集 + 8×A100,复现门槛高。
14.2 v2 分支(2026.05 更新)
2026 年 5 月官方发布了 v2:把底座从 SVD 换成 Wan2.1-VACE-14B (阿里通义万相的开源视频生成模型),环境升级到 Python 3.13 + CUDA 12.8 ,权重仓库改为 TencentARC/StereoCrafter2。这意味着:
- 更强的视频先验(14B 参数 vs SVD 的 ~2.5B)→ 更高质量的修复;
- 新生态(diffusers 更新版本)→ 更友好的 API;
- 代码结构与 main 分支同构(仍是两阶段),迁移成本低。
如果你是新用户,直接上 v2;如果你想知道"扩散视频 inpainting 的最小实现长什么样",main 分支的 SVD 版反而更适合当教材------代码更短、依赖更少、逻辑更透明。
十五、总结:一套值得反复品味的工程范式
StereoCrafter 的价值不在于"发明了一个新算子",而在于它示范了一种当前 AIGC 时代极具普适性的问题解法:
用可解释的传统几何打底(深度 → splatting → 遮挡),把不可解释的部分交给扩散模型(inpainting),再用工程手段(自回归、tiled、offload、零初始化微调)把"能不能跑起来"变成"跑得好不好"。
三个值得带走的源码级知识点:
- 前向 splatting 的加权归一化 :
1.414^disp深度加权 + 双 splat(颜色/权重)归一化 + 全一 splat 出遮挡掩码------25 行代码实现一个可微的立体视角合成器; - 视频扩散 inpainting 的通道拼接 :
[z_t ‖ E(video) ‖ E(mask)]9 通道 + 零初始化新通道,是"在已有视频扩散模型上做新任务"的标准微调姿势; - 自回归 + overlap 的时间一致性:不重新训练模型,仅靠"把上一段生成结果拼进下一段输入"就解决了长视频一致性------性价比极高。
源码拆解到最后你会发现,真正优秀的开源项目,往往不是代码写得有多炫,而是每一个工程决策都踩在物理/数学/经验的正确位置上。StereoCrafter 做到了,这也是它值得一读再读的原因。
参考资源:论文 arXiv:2409.07447;项目主页 stereocrafter.github.io;GitHub TencentARC/StereoCrafter(main & v2 分支);HuggingFace TencentARC/StereoCrafter & StereoCrafter2。