
简介一套基于PyTorch实现单目图像深度估计的完整项目源码包适合具备一定深度学习基础、想通过动手实践掌握单目深度估计的开发者与学生。该算法从单张RGB图像推算像素深度在自动驾驶、机器人导航、三维重建等场景中价值突出。包内共42个文件以Python训练/评估脚本、文本配置说明、Shell实验脚本和Jupyter示例为主整体体积约9.95MB目录按网络结构、数据加载、损失函数、训练器与实验配置等模块划分查阅与调试都比较方便。资源已有560人学习下载源码覆盖从数据预处理、模型构建、训练验证到深度图输出的完整流程同时包含多组不同输入模式的实验脚本便于横向对比。持续运行和调试代码还能帮助理解单目深度估计中的尺度不确定性问题提升将深度学习理论落地到实际视觉任务的能力。1. 单目深度估计是个什么活1.1 为什么单目深度估计值得折腾单目深度估计的核心任务是从一张普通RGB图像中恢复出每个像素对应的场景深度。这件事看起来简单因为人类瞄一眼照片就能大致判断物体远近但对计算机却极不友好。因为单张图像丢失了双目视差和运动轨迹像素的深度天然存在无穷多可能的解。正因为这种病态性纯视觉深度估计一直被认为是三维感知里比较硬核的方向也是下游任务例如三维重建、自动驾驶感知、机器人抓取的基础。我接手这个项目时核心需求很明确用PyTorch搭建一套能够通过单张图像预测稠密深度图的端到端管线并在KITTI数据集上完成训练和评估。选择的基线是编码器-解码器结构编码器用预训练ResNet-18迁移特征解码器通过逐步上采样还原分辨率。项目源码里封装了数据加载、模型构建、训练循环、验证评估以及可视化脚本适合作为研究基线或工程起步代码。单目深度估计的本质是在图像特征和几何深度之间学一个映射这个映射几乎不可能手动设计只能靠数据驱动。1.2 为什么选择PyTorch而不是TensorFlow在搭建这套项目时团队内部其实争论过框架选型。我坚持用PyTorch主要考量了三点。第一动态图的特性对单目深度估计这类研究向项目太友好因为经常需要中途打印特征图、中断后修改loss动态图调试起来直观得多。第二社区资源几乎都围绕着PyTorch无论是预训练权重还是最新论文的开源实现PyTorch版本永远第一时间更新。第三部署环节也没被落下通过TorchScript或ONNX导出模型到C推理端并不困难。实际开发过程中PyTorch的torch.nn和torchvision.models提供了大量现成组件。ResNet、MobileNet、VisionTransformer这些骨干网络基本是开箱即用torch.utils.data也能灵活适配KITTI这种复杂目录结构的数据集。对于要做快速实验的团队来说这种搭积木式的开发体验确实大幅降低了试错成本。2. 跑通第一个训练循环之前2.1 数据加载的注意事项这个项目选用了KITTI Depth Prediction Benchmark作为主要数据来源。原始KITTI数据集包含市区、乡村和高速公路等丰富驾驶场景图像尺寸约为1241x376。需要说明的是KITTI官方的深度真值并不是稠密的而是由Velodyne激光雷达扫描生成经过投影和配准后映射到左相机坐标系因此深度图中存在大量无效像素数值为0或超出有效范围。读取数据时我先将原始PNG深度图加载为numpy.ndarray单位是毫米使用时需要除以256换算为米。预处理阶段关键步骤是过滤无效深度值否则这些像素会被当作0参与损失计算导致模型严重偏向预测近处结果。源码中通过掩码方式处理实现并不复杂但极易忽略。# depth_utils.py import torch import numpy as np def load_depth(path): depth np.array(Image.open(path)).astype(np.float64) / 256.0 return depth def valid_depth_mask(depth, max_depth80.0): mask np.logical_and(depth 0, depth max_depth) return mask def depth_to_tensor(depth): depth torch.from_numpy(depth).float() return depth.unsqueeze(0)训练时还需要考虑数据增强。我采用了随机水平翻转和随机裁剪到320x1024分辨率配合颜色抖动模拟不同光照条件。这里有个小细节翻转操作必须同步作用于RGB图像和深度图并且左右翻转后深度值不变垂直方向深度未变如果使用随机旋转则需要重新投影。项目源码中用一个自定义Dataset类同时返回图像、深度和掩码设计上更简洁也不容易出错。2.2 构建一个能出图的模型模型部分没有引入复杂结构采用经典的编码器-解码器架构。编码器使用torchvision.models.resnet18的预训练权重截断掉最后的全连接层和全局池化保留conv2到conv5四个尺度的特征。解码器采用逐层上采样加跳层连接将深层语义信息逐步恢复为高分辨率深度图。网络输出的是一张单通道深度图没有做任何归一化而是让模型直接回归距离的倒数即视差这是一个非常关键的设计选择。直接用网络回归深度模型倾向于在近距离给出极高精度远距离却严重不准确。将输出转化为视差后再通过1 / output还原深度能显著改善远距离物体的估计质量。# models.py import torch.nn as nn import torchvision.models as models class DepthEstimationNet(nn.Module): def __init__(self, pretrainedTrue): super().__init__() self.encoder models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1 if pretrained else None) self.encoder.fc nn.Identity() self.encoder.avgpool nn.Identity() self.decoder nn.Sequential( nn.Conv2d(512, 256, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.Upsample(scale_factor2, modebilinear, align_cornersFalse), nn.Conv2d(256, 128, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.Upsample(scale_factor2, modebilinear, align_cornersFalse), nn.Conv2d(128, 64, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(64, 1, kernel_size3, padding1), ) def forward(self, x): x self.encoder(x) x self.decoder(x) disp torch.sigmoid(x) # 输出视差范围(0,1) depth 1.0 / (disp 1e-6) return depth这里使用sigmoid将视差限制在0到1之间但实践中这种硬约束有时会限制表达能力。更常见的做法是让网络直接回归视差值最后视差与深度的转换交给损失函数处理。2.3 训练循环中也会翻车很多人以为把数据加载器和模型定义好训练循环几行代码就能搞定实际上这一步最容易出问题。我第一版训练时踩了三个坑。第一个是DataLoader的num_workers设置过高KITTI的深度图是单通道PNG本身压缩率不高多进程读取反而增加内存复制开销导致GPU利用率很低。第二个是输入图像没有做与ImageNet一致的标准化网络加载的是预训练权重但输入像素范围却是0到255前期收敛异常缓慢。还有一个值得注意的坑深度图的无效像素已经做了掩码但损失计算时使用masked_select选中有效像素后如果某个batch恰好有效像素极少比如相机正对天空或远处梯度会变得很不稳定。后来我在损失函数中加入最小像素数限制有效像素少于阈值时跳过该batch训练过程稳定了不少。3. 损失函数与训练策略3.1 直接回归绝对深度的问题既然任务是回归深度最自然的想法就是用L2损失[ L_{2} \frac{1}{N} \sum_{i} (d_i - \hat{d}_i)^2 ]实际跑起来效果却很糟糕。原因是深度值的范围巨大近处0.5米和远处80米的误差量级完全不同。L2损失会被远距离的大误差主导模型牺牲近处的精度来迁就远处的离群点。L1损失虽然对离群点更鲁棒但对于单目深度估计而言同一个物体的尺度不确定性并未被建模会导致预测结果出现全局性的偏移。3.2 尺度不变损失是怎么一回事单目深度估计任务中一个图像可能对应多种合理的深度尺度因此真正需要惩罚的是预测深度与真值的相对误差。目前业界广泛使用的一种损失是BerHu损失它结合了L1和L2的优点对小误差使用L2大误差使用L1。[ \text{BerHu}(x) \begin{cases} |x|, |x| \leq c \ \frac{x^2 c^2}{2c}, |x| c \end{cases} ]其中(c)取整个batch内误差最大值的20%。这个损失函数对近处小误差提供梯度同时对远处的大误差保持鲁棒明显优于纯L1或L2。训练开始时建议先跑一个小的子集做smoke test确认loss能在几十步之内下降再启动完整训练。3.3 我推荐的学习率调度优化器我选用Adam初始学习率设为1e-4权重衰减1e-5。虽然Adam自带适应性学习率但深度估计任务往往需要长时间的训练固定学习率后期会出现loss震荡。我最终采用了OneCycleLR调度器它先让学习率线性上升再余弦下降。使用这种策略后相同epoch数下的RMSE大约下降了7%。超参数配置建议参考下表这份配置在我的场景下能取得比较稳定的效果超参数推荐值说明输入分辨率320x1024保持长宽比避免裁剪过度Batch Size8单卡RTX 3090占用约12GB优化器Adambetas(0.9, 0.999)初始学习率1e-4OneCycleLR中对应最大学习率权重衰减1e-5避免过拟合训练epoch数30配合OneCycleLR收敛稳定数据增强水平翻转、颜色抖动翻转必须同步作用在深度图OneCycleLR的核心思想是先快速探索参数空间再平滑收敛。代码实现很简单我在每个step调用lr_scheduler.step()配合clip_grad_norm_限制梯度范数整个训练过程几乎没有出现过loss发散的情况。4. 评估与可视化别被指标骗了4.1 KITTI离线评估指标模型训练完毕需要用KITTI官方指标衡量性能。这些指标分别衡量误差的不同侧面RMSE和RMSE log关注全局误差Abs Rel和Sq Rel更强调相对误差而δ1.25、δ1.25²、δ1.25³则反映预测与真值接近的像素比例。指标公式含义RMSE(\sqrt{\frac{1}{N}\sum_{i}(d_i - \hat{d}_i)^2})均方根误差对离群点敏感Abs Rel(\frac{1}{N}\sum_{i}\frac{|d_i - \hat{d}_i|}{d_i})绝对相对误差估计远近物体同权重Sq Rel(\frac{1}{N}\sum_{i}\frac{(d_i - \hat{d}_i)^2}{d_i})平方相对误差放大小深度值误差的关注δ1.25最大比例满足max(d/d̂, d̂/d) 1.25准确率的一种表述评估时要特别注意掩码处理。KITTI官方评估脚本默认只计算深度小于80米的像素同时排除激光雷达投影到图像边界外的点。如果你用了不同的裁剪方式评估结果会和官方榜单产生差异。# eval_metrics.py import numpy as np def compute_metrics(pred_depth, gt_depth, mask, max_depth80.0): pred pred_depth[mask] gt gt_depth[mask] pred np.clip(pred, 1e-3, max_depth) gt np.clip(gt, 1e-3, max_depth) thresh np.maximum(gt / pred, pred / gt) delta1 (thresh 1.25).mean() delta2 (thresh 1.25 ** 2).mean() delta3 (thresh 1.25 ** 3).mean() abs_rel (np.abs(gt - pred) / gt).mean() sq_rel ((gt - pred) ** 2 / gt).mean() rmse np.sqrt(((gt - pred) ** 2).mean()) rmse_log np.sqrt(((np.log(gt) - np.log(pred)) ** 2).mean()) return { abs_rel: abs_rel, sq_rel: sq_rel, rmse: rmse, rmse_log: rmse_log, delta1: delta1, delta2: delta2, delta3: delta3, }4.2 深度图可视化Jet色彩映射与错误热力图4.2 深度图可视化定量指标只能说明数值差异实际效果还得通过可视化来判断。深度图可视化的常用方法是把深度值归一化后映射到Jet色彩空间红色表示近距离蓝色表示远距离。因为深度图是单通道我直接用matplotlib的imshow加上cmapjet输出。更实用的做法是同时展示误差热力图。将预测与真值的逐像素相对误差映射到另一个色彩空间这样可以直观发现模型在哪些场景下失效——通常是玻璃表面、反光区域、极远距离或动态物体边界。我在评估流程中会生成一个包含四张子图的对比图原图、预测深度、真值深度、误差热力图。这一堆图像叠加在一起比单纯看指标数值更容易定位问题。深度可视化还有一个细节因为深度分布并不是均匀的直接用线性映射会导致近处物体过曝而远处无法区分。我习惯使用log1p压缩后再做归一化这样远近物体的对比度均匀很多。配合colorbar还可以读取实际距离值方便调试。# visualize.py import matplotlib.pyplot as plt import numpy as np def visualize_depth(depth, path, cmapjet): depth np.log1p(depth) depth (depth - depth.min()) / (depth.max() - depth.min() 1e-8) plt.imsave(path, depth, cmapcmap)5. 踩坑记录与工程化琐事5.1 训练阶段的一个经典错误图像归一化顺序我在这里卡了整整两天。项目源码中有一段图像预处理逻辑它先将图像从0-255缩放到0-1然后执行torchvision.transforms.Normalize(mean[...], std[...])。听起来很常规但问题在于深度图也必须经过同样流程否则训练就会失败。深度图如果直接用像素值除以256转换为米之后送入模型数值范围在0到80之间与网络输出的特征分布完全不匹配在反传播时容易出现梯度消失。解决方案有两个一是将深度图也进行标准化需要记录反变换参数二是采用我后来更偏爱的做法——不对深度图做数值归一化而是让它保持物理单位仅在计算损失前通过torch.log1p压缩数值范围。模型输出的深度范围本身就服从近似长尾分布直接在原空间计算损失更合理。5.2 提升PyTorch训练效率的小改动训练中期我发现GPU利用率只有60%左右排查后发现瓶颈在数据加载。PIL.Image.open加上解码在高分辨率下耗时严重尽管num_workers已经设为8但CPU主频不高导致还是跟不上GPU消耗。第一个优化是把图像和深度图预处理为LMDB格式。虽然前期构建LMDB需要额外时间但训练时从内存映射文件随机读取比从磁盘加载单张PNG快得多显存端GPU利用率能稳定在95%以上。另一个改动是开启torch.backends.cudnn.benchmark True让cuDNN在输入尺寸固定时自动选择最快的卷积算法小幅但免费的性能提升。# 启动训练命令 python train.py --data_path ./data/kitti --model_dir ./checkpoints \ --batch_size 8 --lr 1e-4 --epochs 30 --max_depth 80.05.3 部署阶段的精度与速度平衡训练收敛后模型的参数量约为12M在RTX 3090上单帧推理耗时大约6ms。但如果要部署到嵌入式设备或Jetson平台还需要进一步压缩。我尝试过将ResNet-18替换为MobileNetV3精度损失约3%但速度提升一倍。另外在导出ONNX时发现Upsample使用align_cornersFalse会在部分推理引擎上报错。改为align_cornersTrue后兼容性更好精度几乎无变化。最后需要注意的是推理时仍然需要对输入图像执行与训练时完全一致的标准化参数否则预测结果会出现系统性偏移。对于单目深度估计项目没必要追求完美模型结构。这个项目的核心价值在于构建了一套完整的从数据到部署的工程闭环。拿到一份新的数据集换掉数据加载器修改输入分辨率几分钟内就能看到模型在新场景上的表现。这种快速迭代能力对于一个研究或工程起点来说才是最高效的资产。本文还有配套的精品资源点击获取