简介:这份资源面向计算机视觉方向的研究者与开发者,聚焦基于神经网络的RGB-D图像分割任务,通过融合彩色图像与深度信息提升复杂几何场景下的分割精度,可应用于机器人导航、增强现实与三维重建等场景。项目以DepthAware CNN为核心,涵盖预处理融合、基础网络特征提取、深度处理模块、融合解码与分类后处理等关键环节,并支持调整网络结构、适配自建数据集、优化超参数、更换损失函数以及评估可视化等二次开发。压缩包共70个文件,以41个Python脚本为主体,辅以C、CUDA与头文件等底层实现,另有少量编译产物与说明文档,整体约86KB,结构紧凑便于快速上手。目前已有1413人学习下载,适合希望深入理解RGB-D分割算法并在此基础上开展定制实验的读者参考。
1. 从一张 RGB 图加一张深度图说起:这套分割方案到底解决什么问题
做机器人抓取、室内导航或者 AR 遮挡的同行大概率都遇到过同一个尴尬:单靠 RGB 图像做分割,遇到白墙、纯色桌面、光照突变就翻车,模型把背景和前景糊成一团。RGB-D 图像分割就是冲着这个痛点来的——在彩色三通道之外再叠一路深度通道,让网络同时看到「长什么样」和「离多远」。这次拆的资源是一套基于神经网络的 RGB-D 语义分割实现,核心思路是把深度图当作额外特征与 RGB 分支做融合,输出逐像素类别。它适合三类人:正在做多模态融合课程设计的学生、需要快速搭一个分割 baseline 的算法工程师、以及想搞清楚深度信息到底怎么进网络的自学者。整套代码结构清晰,改起来不费劲,下面按「是什么 → 怎么跑 → 坑在哪 → 怎么调」的顺序拆开讲。
2. 网络结构与数据流:RGB 和 Depth 到底在哪一层握手
2.1 双分支编码器的选型理由
这套实现走的是双分支编码器路线:RGB 走一个分支,Depth 走另一个分支,各自提特征,然后在中间层做融合。为什么不在输入层直接拼成四通道?我试过,四通道输入在浅层还能用,但深度图和 RGB 的统计分布差异太大——RGB 是 0 到 255 的纹理强度,深度是米为单位的距离值,直接 concat 会让 BN 层的均值方差被深度值带偏,训练前期 loss 震荡得厉害。双分支的好处是每个模态有自己的 BN 和卷积核,互不干扰,融合放在特征已经抽象过的层级,语义对齐更自然。
常见做法是编码器用 ResNet 或 MobileNet 做 backbone,把最后的分类头去掉,取 stage3 和 stage4 的特征图。深度分支结构可以照搬 RGB 分支,也可以砍掉一半通道数省显存。融合方式有几种:逐元素相加、通道 concat 后 1x1 卷积降维、或者用注意力模块算权重。这套资源里用的是 concat + 1x1 卷积,实现简单,效果在 NYU Depth v2 这类室内数据集上够用。
2.2 融合模块的实现细节
融合层的位置很关键。放太浅,两个模态特征还没抽象好,融合等于白搭;放太深,空间分辨率已经降了 32 倍,小物体分割精度掉得厉害。我一般会在 stage3 输出(stride 16)和 stage4 输出(stride 32)各做一次融合,然后上采样到原图尺寸做逐像素分类。
import torch import torch.nn as nn class FusionBlock(nn.Module): """RGB 与 Depth 特征融合:concat 后 1x1 卷积降维""" def __init__(self, rgb_channels, depth_channels, out_channels): super().__init__() self.rgb_proj = nn.Conv2d(rgb_channels, out_channels, 1) self.depth_proj = nn.Conv2d(depth_channels, out_channels, 1) self.fuse = nn.Sequential( nn.Conv2d(out_channels * 2, out_channels, 1), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) ) def forward(self, rgb_feat, depth_feat): # 先各自投影到同一通道空间,避免 concat 后通道爆炸 r = self.rgb_proj(rgb_feat) d = self.depth_proj(depth_feat) # 深度特征上采样对齐 RGB 空间尺寸 if d.shape[-2:] != r.shape[-2:]: d = nn.functional.interpolate( d, size=r.shape[-2:], mode='bilinear', align_corners=False) return self.fuse(torch.cat([r, d], dim=1))这段代码里三个参数要盯住:out_channels控制融合后的特征维度,一般设 256 或 512;depth_proj的输入通道取决于深度分支 backbone 的输出;interpolate的align_corners=False是 PyTorch 分割任务里的标准做法,设 True 会导致边缘像素偏移。融合完的特征再送进解码器,解码器可以用简单的双线性上采样加卷积,也可以用 FPN 那种横向连接结构。
2.3 数据加载与深度图预处理
深度图的预处理是这套流程里最容易埋雷的地方。原始深度图可能是 16 位 PNG,单位是毫米;也可能是 32 位浮点,单位是米。网络输入前必须统一量纲,否则不同数据集之间迁移直接崩。我一般会把深度值裁剪到 [0.5m, 5m] 区间,然后归一化到 [0, 1],超出范围的置零当作无效深度。
import numpy as np import cv2 def preprocess_depth(depth_path, min_depth=0.5, max_depth=5.0): """读取深度图并归一化到 [0,1],无效值置零""" depth = cv2.imread(depth_path, cv2.IMREAD_UNCHANGED) if depth is None: raise FileNotFoundError(depth_path) # 16 位 PNG 通常是毫米单位,转成米 if depth.dtype == np.uint16: depth = depth.astype(np.float32) / 1000.0 else: depth = depth.astype(np.float32) # 裁剪并归一化 depth = np.clip(depth, min_depth, max_depth) depth = (depth - min_depth) / (max_depth - min_depth) # 无效深度(0 或 NaN)置零 depth[~np.isfinite(depth)] = 0.0 return depthmin_depth和max_depth要根据你的传感器调。Kinect v2 的有效范围大概 0.5 到 4.5 米,RealSense D435 可以到 10 米但远距离噪声大。归一化之后深度图和 RGB 图一样是单通道浮点张量,送进 DataLoader 时记得和 RGB 做相同的随机裁剪、翻转增强,但深度图不能用颜色抖动。
3. 训练流程与损失函数:从数据到权重的完整链路
3.1 损失函数选型与类别不平衡处理
语义分割的标配损失是交叉熵,但室内数据集类别极度不平衡——墙面和地板占了 60% 以上像素,椅子、书本这些小类经常被淹没。直接上交叉熵,模型会学会把所有像素预测成多数类,准确率看着高,IoU 惨不忍睹。常见做法是交叉熵加 Dice Loss 做加权组合,或者用 Focal Loss 压制易分类样本。
class CombinedLoss(nn.Module): """交叉熵 + Dice Loss,缓解类别不平衡""" def __init__(self, num_classes, ce_weight=0.5, dice_weight=0.5): super().__init__() self.ce = nn.CrossEntropyLoss(ignore_index=255) self.dice_weight = dice_weight self.ce_weight = ce_weight self.num_classes = num_classes def dice_loss(self, logits, targets): probs = torch.softmax(logits, dim=1) targets_onehot = nn.functional.one_hot( targets, self.num_classes).permute(0, 3, 1, 2).float() dims = (0, 2, 3) intersection = torch.sum(probs * targets_onehot, dims) cardinality = torch.sum(probs + targets_onehot, dims) dice = (2. * intersection + 1e-6) / (cardinality + 1e-6) return 1 - dice.mean() def forward(self, logits, targets): ce_loss = self.ce(logits, targets) d_loss = self.dice_loss(logits, targets) return self.ce_weight * ce_loss + self.dice_weight * d_lossignore_index=255是把未标注像素排除在损失计算外,NYU Depth v2 里有些区域没有标签,不设这个参数会引入噪声梯度。ce_weight和dice_weight我一般从 0.5 比 0.5 起步,如果小类 IoU 还是低,把 dice 权重提到 0.7。
3.2 优化器配置与学习率调度
优化器用 AdamW 还是 SGD?这套实现里我建议 AdamW,初始学习率 1e-4,weight decay 1e-4。SGD 虽然泛化可能好一点,但需要更精细的调参,对新手不友好。学习率调度用 cosine annealing,训练 100 个 epoch,前 5 个 epoch 做 warmup 线性升温。
from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR, SequentialLR optimizer = AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) warmup = LinearLR(optimizer, start_factor=0.01, total_iters=5) cosine = CosineAnnealingLR(optimizer, T_max=95, eta_min=1e-6) scheduler = SequentialLR(optimizer, schedulers=[warmup, cosine], milestones=[5])start_factor=0.01表示 warmup 从 1e-6 开始,避免训练初期梯度爆炸。T_max=95是 cosine 周期的总步数,和总 epoch 减去 warmup 对齐。每个 epoch 结束后调scheduler.step(),注意 SequentialLR 的 milestone 是切换点,不是总长度。
3.3 训练循环与验证指标
训练循环里除了常规的 forward-backward-step,还要定期在验证集上算 mIoU。mIoU 比像素准确率靠谱得多,尤其在你关心小类分割效果的时候。计算时用混淆矩阵累加,最后再统一算,不要在 batch 级别平均,否则每个 batch 类别数不一样会引入偏差。
def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0 for imgs, depths, labels in loader: imgs, depths, labels = imgs.to(device), depths.to(device), labels.to(device) optimizer.zero_grad() logits = model(imgs, depths) loss = criterion(logits, labels) loss.backward() # 梯度裁剪,防止深度分支梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() return total_loss / len(loader)clip_grad_norm_的max_norm=1.0是经验值,深度图归一化后梯度一般不会太大,但融合层 concat 后通道翻倍,偶尔会冲高,加个裁剪稳一点。验证时记得model.eval()和torch.no_grad(),不然显存直接翻倍。
4. 避坑与排查:那些让我重跑过训练的血泪经验
4.1 深度图全黑或全白
现象:训练 loss 不下降,可视化深度输入发现整张图接近 0 或 1。原因:深度图读取时没做单位转换,16 位 PNG 直接当 0-255 处理,或者归一化区间设错,把有效深度全裁掉了。解决:先打印深度图的 min/max/mean,确认原始量纲;用cv2.IMREAD_UNCHANGED读 16 位图;归一化区间根据传感器手册设,别照搬别人的 0.5-5m。
4.2 融合后特征图尺寸对不上
现象:torch.cat报错,提示两个张量除通道外其他维度不一致。原因:RGB 和 Depth 分支的输入分辨率不同,或者下采样倍数不一致,导致同一 stage 输出的空间尺寸有偏差。解决:在融合前统一做interpolate对齐到 RGB 特征的尺寸,别假设两个分支天然对齐。如果深度分支用了不同的 backbone,stride 可能差一倍,要手动补下采样或上采样。
4.3 验证集 mIoU 远低于训练集
现象:训练集 mIoU 冲到 0.7,验证集只有 0.3。原因:最常见的是深度图增强和 RGB 不同步——RGB 做了随机裁剪,深度图没做,或者做了不同的随机种子。另一个原因是验证集的深度图分布和训练集不同,比如训练用 Kinect,验证用 RealSense。解决:把 RGB 和 Depth 绑在同一个 transform 里,用相同的随机参数;跨传感器时先做深度直方图对齐,或者干脆在目标域上微调几个 epoch。
4.4 显存溢出但 batch size 已经降到 1
现象:batch size 设为 1 还是 OOM。原因:融合层 concat 后通道数翻倍,中间特征图显存占用比单分支高不少;另外如果解码器用了全分辨率特征,显存峰值在最后几层。解决:把融合后的通道数从 512 降到 256;解码器用逐步上采样而不是一步到原图;开启混合精度训练,torch.cuda.amp能省 30% 到 40% 显存。
4.5 深度分支梯度消失
现象:训练几个 epoch 后深度分支的权重几乎不变,RGB 分支正常更新。原因:深度图归一化后数值范围小,经过多层卷积后梯度衰减快;或者深度分支学习率没单独设,跟着全局学习率走太小。解决:给深度分支单独设一个更大的学习率,比如 RGB 用 1e-4,Depth 用 5e-4;或者在深度分支加残差连接,让梯度有捷径可走。
5. 进阶调优:把 mIoU 再往上推几个点的具体手法
5.1 深度图补全与噪声抑制
消费级深度相机在物体边缘和反光表面会丢深度,这些空洞区域如果直接置零送进网络,等于告诉模型「这里没有信息」,分割边缘就会毛糙。我一般会做一个简单的形态学闭运算填小洞,大洞用邻域中值填充。更讲究的做法是跑一个深度补全网络,但那又是另一个模型了,课程设计级别没必要。
def fill_depth_holes(depth, kernel_size=5): """形态学闭运算填充小空洞""" kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)) # 先把无效区域标记出来 invalid = (depth == 0).astype(np.uint8) # 闭运算填小洞 filled = cv2.morphologyEx(depth, cv2.MORPH_CLOSE, kernel) # 只替换原来无效的像素,有效区域保持原值 result = depth.copy() result[invalid == 1] = filled[invalid == 1] return resultkernel_size别设太大,5 或 7 就够了,再大边缘会被糊掉。这个操作在预处理阶段做一次就行,不用每个 epoch 重复。
5.2 多尺度融合与注意力加权
如果基础版 mIoU 卡在 0.5 上不去,可以试试在融合模块里加通道注意力。SE 模块的实现很简单,全局平均池化后过两个全连接层算通道权重,再乘回特征图。我实测在 NYU Depth v2 上能涨 1 到 2 个点,代价是参数量增加不多。
class SEBlock(nn.Module): """通道注意力:全局池化 + 两层 FC 算权重""" def __init__(self, channels, reduction=16): super().__init__() self.pool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Sequential( nn.Linear(channels, channels // reduction), nn.ReLU(inplace=True), nn.Linear(channels // reduction, channels), nn.Sigmoid() ) def forward(self, x): b, c, _, _ = x.shape w = self.pool(x).view(b, c) w = self.fc(w).view(b, c, 1, 1) return x * wreduction=16是原论文的默认值,通道数少的时候可以改成 8。把 SEBlock 插在融合层之后、解码器之前,效果最明显。
5.3 验证 mIoU 的正确计算方式
最后说一个容易被忽略的点:mIoU 的计算。很多人用sklearn.metrics.jaccard_score逐 batch 算再平均,这是错的,因为每个 batch 出现的类别不一样,平均权重不对。正确做法是维护一个num_classes x num_classes的混淆矩阵,整个验证集跑完后一次性算。
def compute_miou(confusion_matrix): """从混淆矩阵算 mIoU""" intersection = np.diag(confusion_matrix) union = confusion_matrix.sum(axis=1) + confusion_matrix.sum(axis=0) - intersection # 避免除零:union 为 0 的类别跳过 iou = np.where(union > 0, intersection / union, np.nan) return np.nanmean(iou)np.nanmean会自动跳过没有出现的类别,比手动过滤干净。混淆矩阵在验证循环里用confusion_matrix += np.bincount(...)累加,注意把 ignore_index 的像素排除掉。
从那以后我每次跑分割训练,都强制在第一个 epoch 结束后可视化一遍深度输入和融合特征图,确认没有全黑、没有尺寸错位、没有梯度消失,再往下跑。这三步检查花不了五分钟,但能省下重跑一整晚的电费。希望帮到你。
本文还有配套的精品资源,点击获取