三维重建-CVPR2021-NeuralRecon: Real-Time Coherent 3D Reconstruction from Monocular Video
Three-dimensional reconstruction, presented at the CVPR 2021 conference under the title NeuralRecon, introduces a method for real-time coherent three-dimensional reconstruction from monocular video.
论文链接:NeuralRecon: Real-Time Consistent 3D Reconstruction Based on Monocular Video
代码链接:/zju3dv/NeuralRecon
为了深入理解NeuralRecon技术的基础原理,在学习过程中必须先掌握相机相关的基本知识。具体来说,在完成这些步骤后,通过计算每个体素的TSDF值来实现三维重建的过程。
什么是TSDF?
截断符号距离函数(Truncated Signed Distance Function, TSDF)主要用作三维重建中的体素化表示方法。
该方法通过将空间划分为有限大小的立方体单元来进行建模。
该类算法在计算效率方面表现优异。
SDF(Signed Distance Function, Sign Distance Function) 是一种用于描述场景中各点与其最近物体表面之间距离及其方向的空间函数:它通过符号信息指示各点相对于物体表面的位置关系
- 正数 :如果一个空间中的某一点位于物体表面之外,则该点处的SDF 值为正数,并表示该点与表面之间的距离。
- 负数 :如果一个空间中的某一点位于物体内部区域,则该点处的SDF 值为负数。
- 零 :当一个空间中的某一点恰好位于物体表面上时,则该点处的SDF 值等于零。
SDF 是一种连续函数,在其定义域内代表物体表面各点到最近表面的距离,并带有符号值以区分内部与外部。
Truncated Signed Distance Function (简称TSDF, 截断符号距离函数):然而,在实际应用中直接使用SDF会导致存储大量冗余数据(因为大部分点与物体表面的距离较大)。因此,在实际应用中我们会将距离值截断以减少计算量和存储需求。
- TSDF 将那些远离物体表面的点的 SDF 值进行截断,在距离超过特定阈值时将这些点的 SDF 值设为常量。
- 这一做法的好处在于通过减少远离物体表面区域所涉及的冗余计算来提升整体计算效率。

如何计算TSDF值?
在TSDF的计算过程中,在获取图像中的深度信息的基础上(通过模型预测获得),随后利用相机的位姿和内参进行运算以获得体素的深度(计算出体素的深度)。进而计算体素至真实面的距离(具体公式为:d(x) = ds - dv)。若d(x) > 0(当d(x)大于零时),则表明该体素位于真实面前;反之,则表明该体素位于真实面后方。最后通过进一步处理d(x)获得TSDF值。

NeuralRecon
NeuralRecon 是一种基于深度学习技术实现的三维重建算法,在单目视频或多视图图像处理方面表现出色。
该算法通过TSDF计算方法将三维空间中的体素映射到二维图像平面,并确定每个体素在图片中的具体位置。
然后通过TSDF方式提取特征,并构建所有体素的信息模型。
NeuralRecon模型架构图
主要步骤如下:
- 输入项为彩色图像数据及相机姿态信息。
- 初始低分辨率重建阶段基于低分辨率体素网格进行初步重建。
- 递归应用ConvGRU模型更新各层体素特征以保留前一层有用信息。
- 多层感知机(MLP)被用于推断每个体素对应的TSDF值。
- 每一级都将提升体素分辨率,并通过上采样以及特征融合逐步优化各层次细节信息。
- 输出结果则为高分辨率的TSDF场图。

Fragment Posed Images(分段的带位姿的图像输入)
NeuralRecon 接收的是视频中的一连串彩色图像以及相机的姿态数据。
具体来说:
单目视频中的每帧画面即为其中一张彩色图像。
关于相机的姿态数据而言,则包含了内参数与外参数两部分。这些参数对于确定每张画面在三维空间的位置至关重要。
这些画面会被划分为多个批次处理。
每一批则代表一个独立的片段分析单位。
在处理每一个片段时:
系统会基于多角度的画面数据计算出各处的空间距离场,并逐层细化出场景细节。
将各个区域的空间距离场进行融合汇总后,
最终能够构建起完整的三维场景模型。
Coarse-To-Fine Reconstruction(从粗到细的三维重建)
NeuralRecon 使用了 coarse-to-fine(粗到细)的三维重建策略,逐步提升 TSDF 的分辨率,生成更细致的 3D 场景。其流程如下:
(1) 初始粗分辨率体素网格
首先,网络从低分辨率的体素网格开始重建。在这一层,每个体素的特征会通过图像序列中的多视角图像推断出来。多张图像的特征向量会被相加求平均,以此确定该体素的初步特征向量。
(2) ConvGRU 更新机制
在每一层的 TSDF 估计中,NeuralRecon 使用了 ConvGRU 机制来递归更新体素特征。ConvGRU 的作用是将前一层的体素特征与当前层的信息进行融合,保留有用的信息,并逐层改进。这一机制确保了不同层次之间的特征一致性和逐步细化的过程。
(3) MLP 和 TSDF 估计
特征通过 3D CNN 处理后,进入多层感知机(MLP)模块来估计 TSDF 值。在这一步,网络推断出每个体素的 TSDF 值,表示体素距离真实表面的距离。
(4) 逐层增加分辨率
第一层完成后,接下来的层次中,网络会逐渐增加体素的分辨率。这意味着体素的数量会增加,从而提取出更多的细节。在每一层,ConvGRU 机制会确保上一层的特征信息在新的分辨率下被细化并保留。为了对齐不同分辨率的体素网格,上一层生成的 TSDF 会通过上采样操作。
def forward(self, features, inputs, outputs):
'''
:param features: list: 每个图像的特征列表,例如 list[0] : 图像0的金字塔特征 : [(B, C0, H, W), (B, C1, H/2, W/2), (B, C2, H/2, W/2)]
:param inputs: 来自数据加载器的元数据
:param outputs: {} 空字典,用于存储输出
:return: outputs: dict: {
'coords': (Tensor), 体素的坐标 (number of voxels, 4) (4 : batch 索引, x, y, z)
'tsdf': (Tensor), 体素的 TSDF 值 (number of voxels, 1)
}
:return: loss_dict: dict: {
'tsdf_occ_loss_X': (Tensor), 多层次损失
}
'''
bs = features[0][0].shape[0] # 批次大小
pre_feat = None # 前一阶段的特征初始化为 None
pre_coords = None # 前一阶段的坐标初始化为 None
loss_dict = {} # 损失字典初始化为空
# ----从粗到细的过程----
for i in range(self.cfg.N_LAYER): # 遍历网络的每一层
interval = 2 ** (self.n_scales - i) # 当前层体素网格的间隔
scale = self.n_scales - i # 当前尺度
if i == 0:
# ----生成新的坐标----
coords = generate_grid(self.cfg.N_VOX, interval)[0] # 生成初始的网格坐标
up_coords = []
for b in range(bs):
up_coords.append(torch.cat([torch.ones(1, coords.shape[-1]).to(coords.device) * b, coords]))
up_coords = torch.cat(up_coords, dim=1).permute(1, 0).contiguous() # 将批次索引加入坐标
else:
# ----上采样坐标----
up_feat, up_coords = self.upsample(pre_feat, pre_coords, interval) # 上采样前一层的特征和坐标
# ----反向投影(将坐标投影到3D空间)----
feats = torch.stack([feat[scale] for feat in features]) # 提取当前层的特征
KRcam = inputs['proj_matrices'][:, :, scale].permute(1, 0, 2, 3).contiguous() # 投影矩阵
volume, count = back_project(up_coords, inputs['vol_origin_partial'], self.cfg.VOXEL_SIZE, feats, KRcam) # 反向投影计算体素特征
grid_mask = count > 1 # 判断体素是否被多个图像观测到
# ----拼接上一阶段的特征----
if i != 0:
feat = torch.cat([volume, up_feat], dim=1) # 拼接当前阶段和上阶段的特征
else:
feat = volume # 第一层没有上阶段特征
if not self.cfg.FUSION.FUSION_ON:
tsdf_target, occ_target = self.get_target(up_coords, inputs, scale) # 获取目标TSDF和占据值
# ----转换到对齐的相机坐标系----
r_coords = up_coords.detach().clone().float() # 克隆当前坐标
for b in range(bs):
batch_ind = torch.nonzero(up_coords[:, 0] == b).squeeze(1) # 获取属于当前批次的坐标
coords_batch = up_coords[batch_ind][:, 1:].float() # 获取3D坐标
coords_batch = coords_batch * self.cfg.VOXEL_SIZE + inputs['vol_origin_partial'][b].float() # 坐标转换
coords_batch = torch.cat((coords_batch, torch.ones_like(coords_batch[:, :1])), dim=1) # 增加齐次坐标
coords_batch = coords_batch @ inputs['world_to_aligned_camera'][b, :3, :].permute(1, 0).contiguous() # 应用坐标变换
r_coords[batch_ind, 1:] = coords_batch # 更新坐标
# 批次索引放在最后一维
r_coords = r_coords[:, [1, 2, 3, 0]]
# ----稀疏卷积3D骨干网络----
point_feat = PointTensor(feat, r_coords) # 创建稀疏点张量
feat = self.sp_convs[i](point_feat) # 应用稀疏卷积
# ----GRU融合----
if self.cfg.FUSION.FUSION_ON:
up_coords, feat, tsdf_target, occ_target = self.gru_fusion(up_coords, feat, inputs, i) # 通过GRU进行特征融合
if self.cfg.FUSION.FULL:
grid_mask = torch.ones_like(feat[:, 0]).bool() # 全部体素有效
tsdf = self.tsdf_preds[i](feat) # 预测TSDF值
occ = self.occ_preds[i](feat) # 预测占据值
# -------计算损失-------
if tsdf_target is not None:
loss = self.compute_loss(tsdf, occ, tsdf_target, occ_target, mask=grid_mask, pos_weight=self.cfg.POS_WEIGHT) # 计算TSDF和占据损失
else:
loss = torch.Tensor(np.array([0]))[0] # 如果没有目标TSDF,则损失为0
loss_dict.update({f'tsdf_occ_loss_{i}': loss}) # 更新损失字典
# ------为下一阶段定义稀疏性-----
occupancy = occ.squeeze(1) > self.cfg.THRESHOLDS[i] # 判断哪些体素被占据
occupancy[grid_mask == False] = False # 过滤掉不在网格中的体素
num = int(occupancy.sum().data.cpu()) # 计算占据的体素数
if num == 0:
logger.warning('no valid points: scale {}'.format(i)) # 如果没有有效点,发出警告
return outputs, loss_dict
# ------避免内存溢出:如果点数过多,随机采样点-----
if self.training and num > self.cfg.TRAIN_NUM_SAMPLE[i] * bs:
choice = np.random.choice(num, num - self.cfg.TRAIN_NUM_SAMPLE[i] * bs, replace=False)
ind = torch.nonzero(occupancy)
occupancy[ind[choice]] = False # 随机丢弃一些体素
pre_coords = up_coords[occupancy] # 获取占据的体素坐标
for b in range(bs):
batch_ind = torch.nonzero(pre_coords[:, 0] == b).squeeze(1) # 获取批次内的有效点
if len(batch_ind) == 0:
logger.warning('no valid points: scale {}, batch {}'.format(i, b)) # 如果没有有效点,发出警告
return outputs, loss_dict
pre_feat = feat[occupancy] # 获取占据的体素特征
pre_tsdf = tsdf[occupancy] # 获取占据的TSDF值
pre_occ = occ[occupancy] # 获取占据的占据值
pre_feat = torch.cat([pre_feat, pre_tsdf, pre_occ], dim=1) # 拼接特征、TSDF和占据值
if i == self.cfg.N_LAYER - 1:
outputs['coords'] = pre_coords # 输出最终坐标
outputs['tsdf'] = pre_tsdf # 输出最终TSDF值
return outputs, loss_dict # 返回输出和损失字典
python

最关键的一步:反向投影计算体素特征
已经掌握了图片的空间信息及其成像原理,在此基础上可以通过反向投影技术将构建的所有体素点映射至各个观测图像中对应的位置,在这一过程中需要从每张图像中提取相应的表征信息并进行融合处理。
通过累加各图像的信息并计算其平均值得出最终的结果。
投影公式
文献综述:Atlas: End-to-End 3D Scene Reconstruction from Posed Images
其中 K 和 P 是相机的内外参数,在相机坐标系中具体来说,在相机坐标系中完成世界坐标系(3D 体素)到像素坐标的映射关系。

def back_project(coords, origin, voxel_size, feats, KRcam):
'''
将图像特征反投影到形成一个3D(稀疏)特征体积
:param coords: 体素的坐标
dim: (num of voxels, 4) (4: 批次索引, x, y, z)
:param origin: 部分体素体积的原点(体素 (0, 0, 0) 的 xyz 位置)
dim: (batch size, 3) (3: x, y, z)
:param voxel_size: 体素的大小
:param feats: 图像特征
dim: (num of views, batch size, C, H, W)
:param KRcam: 投影矩阵
dim: (num of views, batch size, 4, 4)
:return: feature_volume_all: 3D 特征体积
dim: (num of voxels, c + 1)
:return: count: 每个体素被观察到的次数
dim: (num of voxels,)
'''
n_views, bs, c, h, w = feats.shape # 从特征中提取视图数、批次大小、通道数、高度和宽度
feature_volume_all = torch.zeros(coords.shape[0], c + 1).cuda() # 初始化特征体积,包含额外的深度通道
count = torch.zeros(coords.shape[0]).cuda() # 初始化每个体素被观察到的次数
for batch in range(bs): # 遍历每个批次
batch_ind = torch.nonzero(coords[:, 0] == batch).squeeze(1) # 获取当前批次的体素索引
coords_batch = coords[batch_ind][:, 1:] # 获取当前批次的体素坐标
coords_batch = coords_batch.view(-1, 3) # 将坐标展平为 (num_voxels, 3)
origin_batch = origin[batch].unsqueeze(0) # 获取当前批次的体素体积原点
feats_batch = feats[:, batch] # 获取当前批次的特征
proj_batch = KRcam[:, batch] # 获取当前批次的投影矩阵
grid_batch = coords_batch * voxel_size + origin_batch.float() # 将体素坐标转换到3D空间
rs_grid = grid_batch.unsqueeze(0).expand(n_views, -1, -1) # 扩展坐标以适应每个视图
rs_grid = rs_grid.permute(0, 2, 1).contiguous() # 调整维度顺序
nV = rs_grid.shape[-1] # 获取体素数
rs_grid = torch.cat([rs_grid, torch.ones([n_views, 1, nV]).cuda()], dim=1) # 增加齐次坐标
# 投影体素到图像平面
im_p = proj_batch @ rs_grid # 计算投影
im_x, im_y, im_z = im_p[:, 0], im_p[:, 1], im_p[:, 2] # 提取 x, y, z 坐标
im_x = im_x / im_z # 归一化 x 坐标
im_y = im_y / im_z # 归一化 y 坐标
# 将归一化的坐标转换为图像坐标系
im_grid = torch.stack([2 * im_x / (w - 1) - 1, 2 * im_y / (h - 1) - 1], dim=-1) # 转换到 [-1, 1] 范围
mask = im_grid.abs() <= 1 # 创建掩码,保留在图像范围内的坐标
mask = (mask.sum(dim=-1) == 2) & (im_z > 0) # 检查 z 值是否为正,确保体素在图像平面前面
feats_batch = feats_batch.view(n_views, c, h, w) # 重塑特征张量
im_grid = im_grid.view(n_views, 1, -1, 2) # 重塑图像网格
features = grid_sample(feats_batch, im_grid, padding_mode='zeros', align_corners=True) # 从图像特征中采样
features = features.view(n_views, c, -1) # 重塑特征张量
mask = mask.view(n_views, -1) # 重塑掩码
im_z = im_z.view(n_views, -1) # 重塑 z 值
# 移除无效值(例如 NaN)
features[mask.unsqueeze(1).expand(-1, c, -1) == False] = 0
im_z[mask == False] = 0
count[batch_ind] = mask.sum(dim=0).float() # 记录每个体素被观察到的次数
# 聚合多视图的特征
features = features.sum(dim=0) # 对视图特征进行求和
mask = mask.sum(dim=0) # 对掩码进行求和
invalid_mask = mask == 0 # 找到无效体素
mask[invalid_mask] = 1 # 将无效掩码设置为1
in_scope_mask = mask.unsqueeze(0) # 扩展掩码维度
features /= in_scope_mask # 归一化特征
features = features.permute(1, 0).contiguous() # 调整维度顺序
# 连接归一化的深度值
im_z = im_z.sum(dim=0).unsqueeze(1) / in_scope_mask.permute(1, 0).contiguous() # 计算深度的平均值
im_z_mean = im_z[im_z > 0].mean() # 计算深度的均值
im_z_std = torch.norm(im_z[im_z > 0] - im_z_mean) + 1e-5 # 计算深度的标准差
im_z_norm = (im_z - im_z_mean) / im_z_std # 归一化深度值
im_z_norm[im_z <= 0] = 0 # 将无效深度值设为0
features = torch.cat([features, im_z_norm], dim=1) # 将深度特征与体素特征拼接
feature_volume_all[batch_ind] = features # 更新特征体积
return feature_volume_all, count # 返回特征体积和每个体素被观察到的次数
python

总结
在每个细化阶段的过程中,在每个细化阶段的过程中,在每个细化阶段的过程中,在每个细化阶段的过程中,在每个细化阶段的过程中,
在每个细化阶段的过程中,
在每个细化阶段的过程中,
在每个细化阶段的过程中,
在每个细化阶段的过程中,
在每个细节级别的层面进行特征融合。
网络能够通过多视角图像特征实现不同分辨率下的融合过程。
这一过程能够保证体素特征的一致性。
经过三层计算并逐层细化的过程,
最终生成一个高分辨率的TSDF模型。
这个TSDF值能够用于表示重建后的三维场景。
整个TSDF生成过程是通过逐步改进与融合特征实现的,
这一过程成功捕捉到了场景中的几何细节与深度信息。
生成后的TSDF值可以通过软件实现结果展示,
这些结果展示了最终重建出的空间模型。
该模型是基于融合多张图像以及相机位姿信息构建而成,
其能够在三维空间中呈现连贯且细节丰富的三维模型。
