做单目深度估计和表面法线估计的,应该都遇到过一个问题:公开数据集要么只给深度,要么只在室内,要么标注稀疏到没法用。今天要聊的DIODE,是一个“全新RGB-D及平面法线数据集”,把室内、室外、稠密深度、平面法线一次性凑齐了,而且RGB图像和标注的对齐精度很高。若你正在准备深度估计、法线估计、三维重建或者多任务学习的数据集而不想自己抠图,这篇文章应该能帮你省掉不少时间。我会从数据集的设计思路、目录结构、加载代码、损失函数到常见坑全部梳理一遍,末尾还有一些只有实操才会发现的经验。
1. 先说清楚DIODE到底是什么
1.1 全称和核心定位
DIODE的全称是“Dense Indoor/Outdoor DEpth”,直译就是“稠密室内外深度数据集”。注意它全称里既有Indoor又有Outdoor,这是它的最大亮点:把室内和室外两类深度数据放在同一个数据集里,并且每张RGB图像都对应一份稠密深度图和一份表面法线图。
和很多用消费级深度相机采集的数据集不同,DIODE的深度不是直接“拍”出来的。它主要依赖高精度激光扫描设备对场景进行三维重建,得到稠密点云或网格,然后把三维几何投影到相机视角,生成像素级对齐的深度图。表面法线则是在三维几何上拟合局部平面计算出来的,因此整体标注质量比直接对深度图做差分可靠得多。
项目里的“全新”其实有两层含义:一是发布时相比NYUv2、KITTI这些“老前辈”,它在分辨率、场景跨度、法线标注上都是明显升级;二是它把室内和室外放在统一的标注框架下,方便研究者直接用它来验证跨场景泛化能力。现在很多工作喜欢把深度和法线联合起来训练,DIODE正好是少有的“原生支持”这种多任务需求的开源数据集。
1.2 为什么需要专门做一个这样的数据集
先看几个常见数据集的“痛点”。
NYUv2是目前室内深度估计最常用的数据集,但它是用Kinect一类深度相机采集的,存在深度空洞、边缘噪声大、分辨率不高等问题。KITTI是自动驾驶领域的重要基准,但它用车载激光雷达采集,深度只覆盖路面附近几条线,属于稀疏标注,做稠密深度估计时需要复杂插值,而且没有官方法线标签。ScanNet有大规模室内重建数据,但很多帧的质量参差不齐,法线标签也需要自己从Mesh里算。
简单说,过去做深度估计的研究者要面临一个选择:要室内就没有高密度法线,要室外深度又稀疏,想同时研究多个任务就得自己花大量时间清洗数据。DIODE把室内外场景、稠密深度、平面法线都整合到一起,至少让研究者在“数据准备”这一环省掉很多精力。从模型角度看,有了同时覆盖室内外的数据,模型才有机会学到更通用的几何规律,而不是只记住某个房间的结构。
1.3 和主流数据集的横向对比
这里我用一个表格做一个直观对比,方便你快速理解DIODE的定位。
| 数据集 | 场景范围 | 深度类型 | 官方法线标签 | 大致分辨率 | 传感器/采集方式 |
|---|---|---|---|---|---|
| NYUv2 | 室内 | 稠密但有空洞 | 无单独官方标签 | 640x480 | Kinect深度相机 |
| KITTI | 室外道路 | 稀疏 | 无 | 1240x376 | 车载激光雷达 |
| ScanNet v2 | 室内 | 稠密 | 可从Mesh推导 | 1296x968 | 深度相机+重建 |
| Matterport3D | 室内 | 稠密 | 可从Mesh推导 | 1280x1024 | RGB-D+结构光 |
| DIODE | 室内+室外 | 稠密 | 官方直接提供 | 1080p级别 | 激光扫描+投影 |
从表格能看出来,DIODE不是在所有指标上“最大”,而是在“深度稠密、室内外都有、法线官方提供”这三个维度同时占优势。尤其是法线标签,它不是事后用OpenCV算出来的近似,而是从三维几何表面拟合出来的,对于训练法线估计网络非常关键。
1.4 适合哪些研究场景
如果你正在做下面这些方向,DIODE会比较对胃口:
- 单目深度估计:输入RGB图,输出逐像素深度,在DIODE上训练可以直接看室内外泛化。
- 表面法线估计:官方法线标签,省去自己从深度图反推的麻烦。
- 深度和法线联合估计:多任务学习,一个网络同时输出两个几何量。
- 三维重建和补全:高分辨率稠密深度可用于补全实验。
- 具身智能/机器人感知:机器人需要在室内外不同环境中做深度感知,DIODE适合做预训练数据。
2. 深入拆解数据集的内部结构
2.1 目录与文件命名
DIODE官方发布后,大家从官网下载的数据包通常会按“train/val”划分,里面再按室内外场景类型组织。一个典型的目录结构长这样:
DIODE/ ├── train/ │ ├── indoors/ │ │ ├── scan_1/ │ │ │ ├── 000.png │ │ │ ├── 000_depth.npy │ │ │ ├── 000_normal.npy │ │ │ ├── 001.png │ │ │ ├── 001_depth.npy │ │ │ └── 001_normal.npy │ │ └── scan_2/ │ └── outdoors/ │ └── scan_3/ │ ├── 000.png │ ├── 000_depth.npy │ └── 000_normal.npy └── val/ ├── indoors/ └── outdoors/不过不同时期下载的压缩包命名可能会有一点点差异,建议解压后先执行一条tree或者find命令把结构看明白,再写加载器。我自己就吃过亏,直接按网上老教程的路径写,结果只在训练集里跑通了,验证集的目录层级多了一层,最后排查了很久才发现是文件路径前缀的问题。
2.2 深度图与法线图的存储格式
DIODE的深度图和法线图通常都是.npy格式,对应一个numpy数组。深度图的数值单位是米,比如某个像素的值是3.5,就代表该点到相机平面的距离大约是3.5米。数组的形状和RGB图像完全一样,都是(H, W)的二维矩阵。
法线图则是(H, W, 3)的三维数组,每个像素是一个三维向量。这里有个特别容易踩的坑:不同数据集对法线向量的存储范围不一样。有的直接存[-1,1]的原始向量,有的为了可视化会把向量缩放到[0,1],DIODE官方不同版本也可能有差异。所以读取之后不要直接输入网络,先打印一下depth.min()、depth.max()、normal.min()、normal.max(),确认取值范围再做后续处理。
RGB图像方面,DIODE提供的是高分辨率PNG图片,有的版本可能是8位,有的可能是16位。加载后先检查一下image.dtype,如果模型需要8位输入,需要做好转换。
2.3 室内与室外场景划分
DIODE把场景分成indoors和outdoors两个大子集,这不是简单的“换了个背景”,而是深度特性差异很大的两类数据。
室内场景通常深度范围在几十厘米到十几米,墙面、地面、桌子、家具会形成大量平面结构,法线方向比较“规整”,很多像素的朝向接近垂直或水平。室外场景则复杂得多,可能有几百米远的建筑、树木、车辆,深度动态范围特别大,法线的分布也更加离散。如果直接把所有样本混在一起训练而不做采样调整,室内样本很快会被“学明白”,室外样本则可能一直欠拟合。
因此,很多论文会在训练时按场景类型做均衡采样,比如每个batch里室内室外各占一半,或者按深度范围重新加权。我们在构建自己的Dataloader时也可以这样做,避免模型被某一种场景主导。
2.4 数据质量背后的采集逻辑
DIODE的样本量虽然不像ImageNet那样庞大,但它的每帧标注都来自激光扫描和三维重建,这带来两个直接好处。
第一个好处是对齐精度高。RGB相机和扫描仪经过标定后,三维点云重投影到相机坐标系,生成深度图和平面法线图时不会出现“深度边缘和图像边缘差几像素”的问题。第二个好处是深度是稠密的,没有传统深度相机常见的黑色空洞。你可以把DIODE理解成“用昂贵的离线设备做高质量标注,再用这些标注去训练轻量级的在线估计模型”,和很多依赖传感器直接采样的数据集在质量逻辑上完全不同。
3. 从零开始加载和使用DIODE
3.1 下载与工程部署
下载DIODE需要到官网填写申请信息,一般审核通过后会给一个链接。这里先提醒一句:整个数据集体积不小,尤其是高分辨率RGB图加npy数组,解压后可能占用几十GB甚至更多。下载的时候建议用支持断点续传的下载工具,比如wget -c或者图形化下载器,避免网络波动导致前功尽弃。
解压后的数据目录最好不要放在机械硬盘的默认位置,DIODE的高随机IO会让数据加载变成瓶颈。实际使用中,我会先把数据放到本地SSD,训练过程中再用多进程读取。如果项目组有共享存储,也要注意同时多个Worker读取会不会造成IO抖动。
3.2 最简加载器代码
下面我用Python给你写一个最简加载器的雏形。这里假设每个场景目录下RGB文件形如000.png,对应深度文件是000_depth.npy,法线文件是000_normal.npy。如果你的命名不同,先按实际路径改一下文件后缀规则。
import os import cv2 import numpy as np from pathlib import Path def load_diode_record(rgb_path): rgb = cv2.imread(str(rgb_path)) rgb = cv2.cvtColor(rgb, cv2.COLOR_BGR2RGB) stem = rgb_path.stem # 例如 "000" depth_path = rgb_path.with_name(stem + "_depth.npy") normal_path = rgb_path.with_name(stem + "_normal.npy") depth = np.load(depth_path) normal = np.load(normal_path) # 打印范围,检查存储格式 print("depth range:", depth.min(), depth.max(), depth.dtype) print("normal range:", normal.min(), normal.max(), normal.shape) return rgb, depth, normal if __name__ == "__main__": root = Path("DIODE/train/indoors/scan_1") first_png = list(root.glob("*.png"))[0] rgb, depth, normal = load_diode_record(first_png)这个加载器只是用来验证数据通路,真正训练时还需要加缓存、数据增强、Mask生成等逻辑。不过先跑通这一步,能帮你确认文件路径和npy内容是否和预期一致。
3.3 深度与法线可视化的小技巧
数据和模型打交道,第一件事不是训练,而是可视化。深度图因为动态范围大,直接用matplotlib默认的cmap='gray'看,可能一片黑。我会用inferno颜色映射,并且对深度取对数,让近处和远处的细节都可见。
import matplotlib.pyplot as plt def visualize_depth(depth, valid_mask=None): depth_log = np.log1p(depth) plt.imshow(depth_log, cmap="inferno") plt.colorbar() plt.title("log depth") plt.show()法线可视化稍微麻烦一点。如果法线向量的值域是[-1,1],需要先变换到[0,1]才能正常显示:
normal_vis = (normal + 1.0) / 2.0 normal_vis = np.clip(normal_vis, 0.0, 1.0) plt.imshow(normal_vis) plt.show()如果法线值域本来就是[0,1],那就直接显示。这里强调一句:显示漂亮不意味着存储正确,一定要同时检查取值范围。
3.4 预处理和工程化建议
把加载器变成真正可用的Dataloader时,有几个工程问题需要提前想清楚。
第一是Resize策略。RGB图像可以用cv2.resize配双线性插值,但深度图如果要缩放到模型输入大小,我建议用最近邻插值,避免插值把边缘深度“抹”平滑。第二是法线图。法线是单位向量,双线性插值之后很可能出现模长不等于1的情况,所以Resize之后最好再归一化一次,或者干脆也用最近邻。第三是Mask的处理。数据集中可能存在无效深度区域,通常定义一个valid_mask = depth > 0,后续在计算损失时只让有效像素参与。
如果训练过程中发现读取npy太慢,可以把所有标注预先转成.npz或缓存成内存,再配合num_workers=8,效果会好很多。
4. 在DIODE上训练深度和法线估计模型
4.1 输入标准化与增强
模型输入通常要把RGB标准化到ImageNet的均值和方差,这是CNN预训练模型的通用习惯。深度和法线也需要归一化,但不能粗暴地“除以255”。
对于深度,我一般会先取对数:depth_log = log(depth + 1),再缩放到[0,1]或者[-1,1]。因为室内外深度差异很大,线性缩放会让远距离像素的数值压迫过来,导致模型很难在近距离区域做精细预测。对数变换能缓解这个问题。当然,如果你用的是仿射不变深度损失,也可以不做全局归一化。
数据增强方面,水平翻转是比较安全的操作。RGB图像水平翻转后,深度图也水平翻转,法线图在水平翻转的同时,其x分量需要取反。这是因为法线向量和相机的左右方向是关联的。用代码表达就是:
normal_flip = normal[:, ::-1, :] normal_flip[..., 0] *= -1随机裁剪、缩放也可以做,但要同步处理深度和法线,不能只增强RGB。颜色抖动对深度估计任务影响不大,有时反而会增加训练难度,我自己一般不开或者只加很小的亮度扰动。
4.2 损失函数实现
DIODE同时提供了深度和法线,所以最常见的做法是设计一个多任务损失。深度部分可以选择L1、L2,或者更鲁棒的Berhu损失。简单实现时L1就够用了:
import torch def depth_loss(pred_depth, gt_depth, mask): diff = torch.abs(pred_depth - gt_depth) diff = diff * mask.float() loss = diff.sum() / (mask.float().sum() + 1e-8) return loss法线部分一般用余弦相似度损失。给定预测法线pred_n和真值法线gt_n,损失是1 - cos(夹角),夹角越小损失越低:
def normal_loss(pred_normal, gt_normal, mask): pred_normal = torch.nn.functional.normalize(pred_normal, p=2, dim=1) gt_normal = torch.nn.functional.normalize(gt_normal, p=2, dim=1) cos_sim = (pred_normal * gt_normal).sum(dim=1) # (B, H, W) loss = (1.0 - cos_sim) * mask.float() return loss.sum() / (mask.float().sum() + 1e-8)多任务加权时,深度和法线的损失尺度差异很大。我一般先分别观察两个loss的初始量级,再设定权重,比如total_loss = 1.0 * depth_loss + 0.5 * normal_loss。如果法线loss明显偏大,可以调小法线权重。
4.3 评估指标
评估深度预测质量,多使用绝对相对误差(Abs Rel)、均方根误差(RMSE)、对数误差等。这些指标需要在有效Mask内计算。法线估计则常用角度误差,即预测法线和真值法线之间的夹角,统计平均角度误差、中位数角度误差,以及小于11.25度、22.5度、30度的像素比例。
这里有一个容易忽略的细节:计算法线角度误差前,预测法线和真值法线都需要归一化。否则夹角会偏大,指标自然不好看。很多开源代码里会在输出层之后直接取softmax或归一化,但评估时仍然建议再算一次,避免测试时忘了对输出做后处理。
4.4 Baseline 配置思路
如果你想快速在DIODE上跑一个基础模型,可以先把输入分辨率设为256x192或256x256,使用ResNet编码器作为主干,再用简单的Decoder输出深度图和一个3通道法线图。BatchSize可以设成16或32,具体看显存。不用担心模型结构太简单,先让整个训练闭环跑通,再逐步替换更复杂的模块。
数据准备时要注意,同一个scan_id下的连续帧不要同时出现在训练集和验证集中。因为同一场景的帧高度相似,如果不按场景划分,验证集指标会虚高。DIODE官方已经给了train/val划分,用官方划分是最稳妥的。
5. 常见问题与踩坑排查实录
5.1 读出的深度图一片黑或全为0
这个问题最常见的原因是把深度文件路径读错了,或者把法线文件当成了深度文件。还有一种可能是npy数组是float16类型,显示时matplotlib没有正确处理导致看起来是黑的。建议先检查depth.dtype、depth.shape、depth.min()、depth.max(),再用np.unique看是否有非0值。如果整个数组确实全为0,那就是文件对应关系错了,回头核对命名规则。
5.2 法线图偏色或出现彩色条纹
法线图偏色通常有两个原因:一是法线值的取值范围不是预期的[-1,1],你把[-1,1]的向量当成[0,1]直接显示了;二是法线可视化时通道顺序不对。检查顺序应该是:先打印normal.min()和normal.max(),确认是[-1,1]还是[0,1];再把前几个像素打印出来,看x、y、z分量是否符合直觉。彩色条纹往往是Resize过程中用了双线性插值,导致相邻像素法线方向被“平均”,出现伪影。遇到这种情况就改用最近邻插值。
5.3 训练时Loss爆高或NaN
深度估计的NaN问题十有八九是没有屏蔽无效像素。DIODE虽然稠密,但不代表每一个像素都有有效深度。如果有效Mask没做好,无效区域的0值或异常值会被计入损失,梯度自然就爆了。法线同理,如果法线数组在无效区域是NaN,计算normalize时会出现NaN传播,最后整个loss变成NaN。解决的思路是:定义统一的valid_mask,这个Mask叠加深度有效区域和法线有效区域,在损失计算前相乘。
5.4 加载速度严重拖慢训练
DIODE是高清图加npy,单帧数据量比MNIST这类小图标大得多。如果直接在Dataset里每次读取npy,GPU可能每时每刻都在等数据。建议把这个数据集预先处理成更适合训练的文件格式。最省事的是把所有样本存成.npz,也可以使用lmdb,或者干脆把深度和法线以uint16格式压缩存储。我自己的做法是先做一遍“缓存版数据集”,把所有样本打平到内存中,如果内存不够再分片处理。
5.5 数据划分泄露导致指标虚高
如果你不用官方划分,而是把目录里所有图像按8:2随机切分,很可能同一个扫描场景下的相邻帧分别进了训练集和验证集。由于这些帧实在太像,模型在训练时几乎“见过”验证帧,最终指标会虚高。正确做法是按scan_id分组,一个场景的所有帧只能属于训练集或验证集,不能跨集合。
下面把常见问题和排查方式整理成表格,方便你直接对照。
| 问题现象 | 可能原因 | 快速排查与解决 |
|---|---|---|
| 深度全黑 | 读取错文件、dtype异常 | 打印shape、dtype、min、max |
| 法线偏色 | 取值范围未还原、通道顺序错 | 打印像素向量,按[-1,1]或[0,1]转换 |
| Loss为NaN | 无效像素参与损失、法线含NaN | 构造valid_mask,屏蔽无效区域 |
| 加载太慢 | 高分辨率npy随机IO | 缓存为npz/lmdb,增加num_workers |
| 指标虚高 | 同一scan场景跨集合 | 按scan_id划分train/val |
6. 我的实操心得和后续扩展
6.1 拿到数据集后先别写代码,先做可视化
我见过不少同学拿到DIODE第一件事就是复制网上的Dataloader,然后直接train,最后发现法线通道都是反的,白跑了好几个epoch。我的建议是,拿到任何一个新数据集,第一件事就是写一个最朴素的可视化脚本,随机选择5到10帧,把RGB、深度、法线、Valid Mask画在同一个图册里。花一个小时把数据看明白,后面能省一天的时间排查问题。DIODE虽然官方说明写得很清楚,但不同打包版本的文件命名和具体数值范围还是有差异,不要想当然。
6.2 从DIODE迁移到其他任务的思路
DIODE适合做预训练,这点很实用。我们可以在DIODE上先训练一个深度和法线的联合预测模型,然后把backbone接回到自己的垂直场景,比如机器人导航或工业视觉检测。因为DIODE同时覆盖室内外,backbone能学到相对通用的几何表征,比直接用ImageNet预训练可能效果更好。当然,这也需要实验验证,不同任务收益不同,但至少在深度估计领域,我实测下来DIODE预训练后的微调收敛速度是明显更快。
6.3 一点小建议
最后再分享一个自己常用的技巧:如果DIODE的图像分辨率较高但显存受限,不要直接在原始分辨率上训练,而是采用“随机裁剪”策略,先从训练图里裁出512x512或384x384的小块,再输入网络。这样既能保留细节,又能等价于一种数据增强,还能把BatchSize提上去。等模型稳定后,再在更高分辨率上微调几个epoch,往往能拿到一个精度和显存兼顾的结果。
如果后续想把DIODE用到更系统的工程里,我会建议自己做一套数据质量检查工具,定期统计depth的范围、normal的分布、mask占比,把这些指标画成曲线。数据质量稳定了,模型训练才有意义。这一点无论是做研究还是做产品落地,都是通用的经验。