简介:面向计算机视觉研究与工程开发者的单目深度估计项目 Depth-Anything-V2,利用深度学习从单张图像恢复场景深度,可应用于机器人导航、增强现实、三维重建等场景。压缩包内共 99 个文件,大小约 89.99MB,主体为 38 个 Python 脚本和 20 张示例图片,脚本覆盖模型定义、训练与推理、视频/点云处理等环节;另有样例数据、演示视频、Markdown 说明、配置文件与 License,便于复现与二次开发。压缩包结构清晰,资源提供 metric_depth 模块及多个入口脚本,examples 目录可直观对比不同场景下的深度输出效果,README 也标明了使用方式。目前已有 238 人浏览学习,适合想系统上手最新深度估计模型、理解单目深度估计原理并快速开展实验的中高级开发者;拿到包后可直接从 run.py、app.py 等入口体验,也可参考训练脚本调整模型。
1. Depth-Anything-V2 是什么:一张普通照片,凭什么能估出连续深度
某开发者第一次跑 Depth-Anything-V2 的时候,输入是一张随手拍的雨天街道照片,没有深度传感器,没有点云,输出却是一张逐像素连续变化的深度图:近处的车、路面的反光、远处的楼全都分开了层级。这就是单目深度估计——用一张 RGB 图直接推断每个像素的远近。Depth-Anything-V2 是这个方向上目前非常值得试跑的模型版本,它用视觉 Transformer 做主干,以零样本泛化和边缘细节锐利著称。简单说,V2 在上一代的基础上调整了训练目标和深度头的设计,让输出的相对深度更稳定、细节更清楚,尤其在室内场景里不会把墙和家具糊成同一层。
这篇内容适合三类人:做三维重建想快速获取单张图深度先验的;做避障或分割不想依赖深度相机的;以及做过 MiDaS、嫌弃旧模型细节太肉想换方案的人。后面每一章都围绕一个具体问题展开——指标怎么读、命令怎么写、微调怎么配、上线前会踩哪些坑。读完你至少能独立跑通一条从单张图到深度图再到尺度标定的完整链路。
2. 先搞清楚 Depth-Anything-V2 的底细:单目深度估计为什么选它,不选 MiDaS 或前作
2.1 模型结构:ViT 编码器加 DPT 解码头,它到底在算什么
单目深度估计的本质是一个回归任务:输入一张三通道图片,输出一个和原图同分辨率的单通道图,每个像素值表示该点到相机的远近。传统做法是设计一个编码器提取多尺度特征,再接一个上采样头逐像素回归深度。Depth-Anything-V2 沿用了这个思路,但把编码器换成了视觉 Transformer,解码器用的是 DPT 风格的密集预测头。ViT 的好处是能建模全局依赖:判断一片区域是墙面还是走廊,往往需要看整张图的上下文,而不是只看局部纹理。
V2 和前作最明显的变化,公开资料里通常集中在两点:一是编码器的预训练策略调整,让模型在不同数据域之间迁移时更稳;二是深度解码头对局部细节的保留更好,边缘不再是一条条模糊的渐变带。实际跑起来你会发现,家具轮廓、人体边缘、树叶缝隙这些地方,V2 的深度边界比老方案干净得多,这一点对后续做点云后处理非常关键,因为深度边缘糊了,三维重建的物体边界就会跟着糊。
2.2 精度与速度的取舍:vit-s、vit-b、vit-l 三档怎么选
Depth-Anything-V2 的公开权重通常按编码器尺寸分成几档,vit-s 最小,vit-l 最大。选型时先回答三个问题:你的输入分辨率是多少,显存预算有多少,延迟容忍度是多少。我这里给一个常见的经验对应关系,具体数字会随推理框架和显卡变化,但趋势是稳定的。
| 编码器 | 建议输入分辨率 | 显存占用趋势 | 典型使用场景 |
|---|---|---|---|
| vit-s | 512 以内 | 低,2GB 级可跑 | 移动端、实时预览、批量离线任务 |
| vit-b | 512~1024 | 中等,6GB 级可跑 | 桌面工具、科研验证、边缘设备 |
| vit-l | 1024 以上 | 高,建议 10GB+ | 高质量三维重建、离线精细深度 |
我一般的做法是先用 vit-b 把完整流程走通,确认效果满足需求后再决定要不要升 vit-l。很多人一上来直接上 vit-l 加 2048 分辨率,结果显存爆了又回去降分辨率,折腾半天。记住一个原则:单目深度估计对分辨率的敏感度很高,低分辨率下边缘信息会被直接抹掉,与其用最大的模型跑小图,不如用中等模型跑大图。
2.3 和常见替代方案对比:读指标也要读适用边界
很多人会拿 Depth-Anything-V2 和 MiDaS 对比。MiDaS 是单目深度估计里一个绕不开的经典方案,它的优势是生态成熟、小模型多、部署资料好找。V2 的优势主要体现在两个方面:零样本泛化能力更强,换个没见过的数据域不用微调也能出相对合理的深度;细节保持更好,薄物体和复杂边缘不容易糊成一片。如果你的任务是室内外通用深度估计,V2 通常起步体验更好。
另一类对比对象是真正输出绝对深度的方案,比如基于双目或结构光的深度相机。这个要分清楚:Depth-Anything-V2 输出的是相对深度,它知道 A 比 B 近,但不知道 A 距离相机是 1 米还是 2 米。没有标定或额外先验,你不能直接拿它去算物理尺寸。这一点会在后面专门讲怎么救。选型时先想清楚:你要的是"分得清远近"还是"量得出距离",前者 V2 直接能用,后者需要加一层尺度标定。
3. 用官方推理脚本跑通第一帧:最小命令与逐行参数
3.1 准备环境与权重:依赖、模型文件、编码器对应关系
建议准备一台带 NVIDIA 显卡的 Linux 机器,显存不用很大,6GB 跑 vit-b 就够。环境上需要 Python 3.8 以上的解释器、PyTorch 的稳定版、TorchVision、OpenCV 的 Python 包,以及 NumPy。这些依赖用 pip 装就行,不需要额外编译什么扩展。如果只有 CPU,也能跑,只是 vit-l 会慢到让人失去耐心,vit-b 勉强可以接受。
权重文件按编码器分档存放,去模型仓库页找到对应 vit-s、vit-b、vit-l 的 checkpoint 下下来,放到一个 models 目录下。下载后先别急着跑,看一眼文件大小和 SHA 校验值,这一步能省掉后面大半的玄学报错。常见翻车现场是有人把 vit-b 的权重配给 vit-l 的模型结构,加载时直接报 key 不匹配,或者更隐蔽的:权重能加载进去,但输出的深度图一团糟。
# 示例:目录结构 Depth-Anything-V2/ ├── run.py ├── models/ │ ├── depth_anything_v2_vits.pth │ ├── depth_anything_v2_vitb.pth │ └── depth_anything_v2_vitl.pth └── demo.jpg3.2 推理命令与输出产物:图片进,深度图出
仓库里通常带一个推理入口脚本,参数一般包括编码器类型、输入图片路径、输出目录和可选的深度上限。以 vit-b 为例,命令大致长这样:
python run.py --encoder vitb --img-path ./demo.jpg --outdir ./output --max-depth 20逻辑说明:这个命令加载 models 目录下对应的 vitb 权重,把 demo.jpg 预处理成模型要求的尺寸和通道顺序,前向推理得到深度图,再保存到 output 目录。--max-depth 参数控制的是可视化时的深度映射范围,它不影响模型内部的推理,只影响导出图的明暗分布。调小这个值会让近处物体的亮度拉开差距,方便直接肉眼检查。
参数说明:--encoder 必须和权重文件对应,vitb 就配 vitb 权重;--img-path 可以指向单张图片或一个存放多张图片的目录;--outdir 不存在时会自动创建。如果跑完发现输出目录里只有图没有数值文件,说明脚本默认只做了可视化,需要自己写保存逻辑时,往下看 3.3 节。
# 验证输出的快速方式 python -c "from PIL import Image; im = Image.open('output/demo.png'); print(im.size, im.mode, im.getextrema())"这段命令用来确认输出图片的尺寸、通道模式和像素值范围。合理的结果通常是单通道灰度图,像素范围有明显的明暗分布。如果 getextrema 返回 (0, 0) 或 (255, 255),说明深度图没有正常写出来,先检查输入图片是否成功加载。
3.3 自己写加载脚本时的通用写法:状态字典 key 与输入预处理
跑通官方脚本只是第一步,产品化的时候你大概率要自己写加载逻辑。核心就三件事:加载权重、前向推理、后处理保存。下面是一个通用骨架,模型定义部分以你自己下载到的代码为准,注意对齐状态字典的 key 名。
import torch import torchvision.transforms as T import numpy as np from PIL import Image # 1. 加载模型 model = build_depth_model() # 用仓库里的模型定义函数 state_dict = torch.load("models/depth_anything_v2_vitb.pth", map_location="cuda") model.load_state_dict(state_dict, strict=True) model = model.cuda().eval() # 2. 预处理:图像缩放 + 归一化 transform = T.Compose([ T.Resize((518, 518)), # 常见输入大小,可调 T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) img = Image.open("demo.jpg").convert("RGB") x = transform(img).unsqueeze(0).cuda() # 3. 推理与后处理 with torch.no_grad(): depth = model(x) # 形状: [B, 1, H, W] depth = depth.squeeze().cpu().numpy() depth = (depth - depth.min()) / (depth.max() - depth.min()) # 归一化到0-1 depth_16bit = (depth * 65535).astype(np.uint16) Image.fromarray(depth_16bit).save("output/depth_16bit.png")逻辑说明:模型定义函数从仓库代码里拿,不要自己重写结构;加载权重时 strict=True 能立刻暴露 key 不匹配的问题。预处理里的均值标准差用的是 ImageNet 常见统计值,如果你的输入是灰度图或特殊传感器图,要重新算。后处理里做了最小最大归一化,这一步让深度图变成 0 到 1 的相对值,方便统一保存格式。
参数说明:Resize 到 518 是一个兼顾速度和质量的经验值,不要小于 384,否则边缘细节会大量丢失;保存成 16 位 PNG 是为了不损失深度精度,8 位图在暗部会有明显断层。如果你后续要做尺度标定或三维重建,建议直接保存成 npy 格式保留原始浮点深度,不要走 PNG 的整型量化。
4. 微调 Depth-Anything-V2:冻结主干还是全量微调,损失函数怎么配
4.1 什么时候该微调,什么时候不该微调
零样本泛化强不等于万能。Depth-Anything-V2 在自然场景、室内场景上表现很好,但换到了一些它没见过的高反差、非朗伯体或严重遮挡场景就会翻车。最典型的是水下摄影、内窥镜画面、工业零件特写这类域差距大的数据,模型可能把近处的透明玻璃当成没有深度的区域,或者把雾气当成统一的前景。遇到这种情况,第一反应不应该是改后处理,而是收集一批目标域数据做微调。
那什么时候不该微调?如果你的目标域和自然图像差别不大,比如室内监控、车载前视、机器人巡视,V2 的表现通常已经足够,微调反而可能引入过拟合。另外一个常见判断标准是:先跑一批测试图看深度图是否合理,如果 80% 的图肉眼看着都对,就别动模型,去解决尺度标定问题更划算。微调是会消耗标注成本和时间成本的,不是默认动作。
4.2 数据组织与标注格式:深度图怎么进训练循环
微调需要成对数据:一张 RGB 图配一张深度图。深度图可以是真实传感器采集的,也可以是现有单目模型生成的伪标签,但伪标签的精度上限受限于生成模型本身。数据组织上常见做法是一个目录放 RGB,另一个目录放深度图,文件名一一对应。深度图的格式要统一,如果用的是 16 位 PNG,要明确它的缩放系数,比如某个数据集里像素值除以 256 才是真实米数,这个系数一旦在训练和验证时不一致,损失函数会直接乱掉。
import numpy as np from PIL import Image import torch from torch.utils.data import Dataset class DepthDataset(Dataset): def __init__(self, rgb_dir, depth_dir, scale=1.0): self.rgb_paths = sorted(rgb_dir.iterdir()) self.depth_paths = sorted(depth_dir.iterdir()) self.scale = scale def __getitem__(self, idx): rgb = Image.open(self.rgb_paths[idx]).convert("RGB") depth = Image.open(self.depth_paths[idx]) depth = np.array(depth).astype(np.float32) / self.scale # 深度图中的无效区域,常见做法是置为0并记录mask mask = (depth > 0).astype(np.float32) rgb = T.Compose([T.Resize((518, 518)), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])])(rgb) depth = torch.from_numpy(depth).unsqueeze(0) mask = torch.from_numpy(mask).unsqueeze(0) return rgb, depth, mask逻辑说明:这个 Dataset 的核心是把深度图读成浮点数并除以缩放系数,转成和 RGB 相同空间尺寸的张量。mask 的构造很关键,深度传感器在反光面、远距离、黑物体上经常给不出有效值,这些位置如果参与损失计算,会把模型往错误方向拉。RGB 的预处理要和推理时保持一致,否则训练和测试的输入分布不匹配。
参数说明:scale 参数完全取决于你的深度图来源,可以在数据准备阶段先用一个已知深度的场景验证。Resize 尺寸训练时用 518 或稍低都可以,但如果推理时用的是其他尺寸,两者差距不要太大,否则深度头的感受野会不匹配。mask 里被置零的区域在损失函数里也要显式排除。
4.3 最小微调脚本骨架与三个必调参数
微调策略上,我倾向于先冻结主干,只训练深度解码头。原因是 ViT 主干在预训练阶段已经学到了足够通用的视觉特征,目标域和自然图像的差距主要体现在深度头如何把这些特征映射成深度。全量微调在数据量不足时很容易过拟合,还会让模型忘记通用的深度先验。等冻结训练收敛后,如果你还有余量数据,再解冻最后两层 ViT 做低学习率的精调。
model = build_depth_model() state_dict = torch.load("models/depth_anything_v2_vitb.pth") model.load_state_dict(state_dict) # 冻结主干 for name, param in model.named_parameters(): if "head" not in name: # 按实际模块名调整 param.requires_grad = False optimizer = torch.optim.AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr=1e-4, weight_decay=0.01 ) criterion = silog_loss # 尺度不变对数损失 for epoch in range(30): for rgb, depth, mask in dataloader: pred = model(rgb) loss = criterion(pred, depth, mask) optimizer.zero_grad() loss.backward() optimizer.step()逻辑说明:冻结主干的关键是按模块名过滤参数,不同仓库的命名不一样,一定要先打印出参数名看清哪些属于 depth head。SILog 损失函数的核心思想是用对数空间里的误差和方差来评估深度,对相对深度特别友好:它不要求预测值和真实值在绝对尺度上对齐,只要求相对远近关系正确,这和 Depth-Anything-V2 的输出特性是吻合的。
参数说明:学习率 1e-4 是个保守起点,如果 loss 降得太慢可以提到 5e-4,但微调阶段超过 1e-3 很容易让 ViT 的权重产生灾难性遗忘。weight_decay 默认 0.01 不用动。批量大小取决于显存,vit-b 下 4 到 8 比较常见;批量太小的时候建议用梯度累积模拟更大的 batch。训练过程中每个 epoch 结束要在验证集上算一次深度指标,而不是只看训练 loss。
5. 避坑:Depth-Anything-V2 上线前的 6 个常见拦路点
5.1 现象、原因、解决,六条血泪经验
第一条坑:输出深度图全黑或者全白。现象是跑完推理后保存的图片要么一片漆黑要么一片惨白,肉眼根本看不出结构。原因几乎都出在值域处理上:模型输出是浮点张量,范围可能是负值也可能集中在很窄的区间,直接转成 8 位整数后全被截断到 0 或 255。解决方法是先做最小最大归一化再量化,或者保存成 16 位 PNG 和 npy 保留原始范围,不要在 8 位量化上省事。
第二条坑:加载权重报错,提示 key name 不匹配。现象是 load_state_dict 时出现 missing keys 和 unexpected keys 一大片。原因多半是编码器型号选错,或者把不同版本的权重混用。解决方法是先确认 checkpoint 里的 key 前缀是什么,再对比模型定义的对应模块;用 strict=True 加载能让这类错误立刻暴露,不要改成 strict=False 假装无事发生。
第三条坑:换成 vit-l 后显存直接溢出。现象是 8GB 显卡上跑 1024 以上分辨率报 CUDA out of memory。原因很明显:vit-l 的参数量和中间激活值都非常大,加上 DPT 头在整个分辨率上做上采样,显存消耗会随着输入尺寸超线性增长。解决方法是先降到 518 分辨率确认流程,再考虑换个中等尺寸的编码器;如果必须用大模型大分辨率,可以把输入切块做滑窗推理,再把深度图拼接回去。
第四条坑:同一场景连续两帧的深度值忽大忽小。现象是视频里深度图看着没什么问题,但某个物体的相对深度在不同帧之间跳动。原因不能怪模型,单目深度估计输出的是相对深度,没有全局尺度约束,任何输入上的微小抖动都会被放大成输出上的尺度漂移。解决方法是接一个时间维度的平滑,或者引入一个已知深度的参考点做每帧尺度校正,这一条在第 6 章详细展开。
第五条坑:透明玻璃和白色高光墙面出现深度断层。现象是玻璃区域深度突然变成背景的深度,或者白色墙面不同位置深度差异很大。原因在于单目深度估计只能靠颜色和纹理线索推断深度,透明材质没有可靠视觉特征,高光区域又会被当成不同平面。解决方法是不要指望模型在物理上正确,后处理里检测这类区域并做插值,或者干脆在业务逻辑里把这类区域标记为不可信。
第六条坑:微调后模型在通用场景上的表现明显变差。现象是目标域效果上去了,但拿回原来的自然图片测试,深度图质量断崖式下跌。原因是灾难性遗忘:微调时学习率太高或者训练太久,ViT 主干产生了大幅偏移。解决方法是一开始就冻结主干,只训解码头;如果必须要全量微调,学习率降到 1e-5 级别,并且每个 epoch 混入一部分原始通用数据一起训练。
5.2 一帧图自检模型好坏的三个快速步骤
任何新场景上线前,我都建议先拿一帧有代表性的图做三件事。第一步看深度图的直方图分布,正常的深度直方图应该呈连续分布,有多个峰,如果整个直方图挤在一个极窄区间,说明模型没有区分出场景层次。第二步做边缘对齐检查,把深度图的梯度叠加在 RGB 图上,看人物、车辆、家具的边缘是否和深度突变位置重合,如果深度边缘偏离物体边缘两三个像素,说明深度头的细节能力不够。第三步检查远处区域的平滑性,天空或者远处墙面在真实世界里深度变化很小,模型输出也应该平滑渐变,如果出现一堆细碎噪点,说明场景对模型来说太陌生,需要微调而不是修后处理。
6. 把 Depth-Anything-V2 接到产品里:深度尺度的最后一公里标定
6.1 用已知距离做线性映射,把相对深度换算成真实距离
模型输出是相对深度,你要的是"这面墙离我 2.3 米",那就得做一次尺度标定。最实用的方法是线性映射:假设相对深度 d 和真实深度 z 满足 z = a * d + b。标定的思路是找两个已知距离的参考点,联立方程解出 a 和 b。你可以在地上放一个标定物,量出它到相机的真实距离,再读出模型输出的相对深度,两次测量解一个二元一次方程组就够了。
import numpy as np # 已知的真实距离和模型输出深度 mul = 1.0 z1, z2 = 1.5, 4.0 # 两个参考点的真实距离,单位:米 d1, d2 = 0.2, 0.8 # 模型输出对应的相对深度,需在同一张图上取值 a = (z1 - z2) / (d1 - d2) b = z1 - a * d1 # 应用映射 depth_relative = np.load("output/depth.npy") # 模型原始输出 depth_real = a * depth_relative + b逻辑说明:取参考点时要注意 d1 和 d2 不能选得太近,否则线性拟合的数值稳定性很差。两个参考点覆盖的范围越宽,映射的可靠性越高。如果你能取到三个点,就用最小二乘拟合,它能平均掉单点取值时的噪声。这个步骤做完后,深度图就有了物理意义,后续做三维重建或避障才能在真实尺度下运行。
参数说明:a 和 b 只在相机的内参和外参保持不变时有效。一旦换了相机、换了对焦距离、甚至裁了画面尺寸,都需要重新标定。标定的另一个陷阱是相对深度和真实深度在近距离可能不是严格线性关系,如果你的业务场景主要覆盖 0.5 米以内的近距离,建议用多项式拟合或者查找表。
6.2 多帧一致性验证,别让深度在时间轴上跳舞
标定完成后,AOI 场景里最常做的验证是让相机固定不动,拍一段视频,检查静态物体的深度输出是否恒定。单帧深度图看着都合理,但放到视频里深度会抖动,这种问题在标定之后依然存在,原因是输入图像在时间轴上的噪声被端到端模型放大成了深度变化。验证方法是在画面里选三个静态区域,分别记录每帧输出的深度值,画一条随帧数变化的曲线,正常情况下曲线应该平稳,如果波动超过真实距离的 5% 到 10%,就要引入时间域滤波。
我自己的习惯是把这条验证流程固化下来:每换一个场景,先标定尺度,再做多帧稳定性测试,最后才真正接入业务逻辑。这套流程看着简单,但能省掉后面大量莫名其妙的"深度不准"返工。深度估计这类任务,模型出了问题可以换,尺度出了问题才是真正难排查的,因为它藏在两套坐标系的转换里。希望帮到你。
本文还有配套的精品资源,点击获取