3、三维重建-NeuralRecon
NeuralRecon论文链接:NeuralRecon: Real-Time Coherent 3D Reconstruction from Monocular Video
在了解NeuralRecon之前,需要先了解相机相关的知识,实际上三维重建,最后计算得出的是每个体素的TSDF(Truncated Signed Distance Function)值。
什么是TSDF?
TSDF(Truncated Signed Distance Function,截断的符号距离函数)是一种常用于三维重建的体素表示方法,广泛应用于像 NeuralRecon 这样的三维重建系统。
SDF(Signed Distance Function,符号距离函数): 符号距离函数是一个空间函数,用来表示场景中每个点到最近物体表面的距离,同时包含符号信息:
- 正值:如果空间中的某个点位于物体表面之外,SDF 的值为正,表示点离表面的距离。
- 负值:如果某个点在物体表面内部,SDF 的值为负。
- 零值:如果某个点正好在物体的表面上,则 SDF 的值为 0。
SDF 是一个连续函数,表示物体表面的所有点距离最近表面的距离,并且是带符号的,能区分点在物体内部还是外部。
TSDF(Truncated Signed Distance Function,截断符号距离函数): 由于直接使用 SDF 来表示整个三维空间可能会产生大量不必要的数据(因为大多数点离物体表面较远,距离较大),因此在实际应用中我们会对距离进行截断,得到 TSDF。
- TSDF 会将那些离物体表面较远的点的 SDF 值进行截断,超过某个距离阈值的点直接设为常量。
- 这样做的好处是减少了远离物体表面区域的不必要计算,从而提高计算效率。
如何计算TSDF值?
在计算 TSDF 时,首先通过模型预测获得图片中的深度
ds
(从图片中获取的深度信息),然后根据相机的位姿和内参计算出体素的深度dv
(通过相机拍摄的位姿以及相机的内参计算得来),接着计算体素到真实面的距离d(x) = ds - dv
。若d(x) > 0
,则体素位于真实面前;若d(x) < 0
,则体素位于真实面后。最终,通过进一步处理d(x)
得到 TSDF 值。
NeuralRecon
NeuralRecon 是一种基于深度学习的三维重建方法,主要用于从单目视频或多视图图像生成高质量的三维场景。NeuralRecon利用TSDF的计算方法,将三维空间中的体素投影映射到二维图像中,找到体素需要提取图片中的具体位置,采用TSDF方式提取特征,构成所有的体素。
NeuralRecon模型架构图
主要步骤:
- 输入:彩色图像和相机位姿信息作为输入,提供三维场景的基本信息。
- 初始低分辨率重建:从低分辨率的体素网格开始,通过图像的多视角信息推断初步体素特征。
- ConvGRU 更新特征:使用 ConvGRU 递归更新每层体素特征,保留前一层的有用信息。
- MLP 估计 TSDF:多层感知机(MLP)用于估计每个体素的 TSDF 值,表示体素与表面的距离。
- 逐层细化:每一层增加体素分辨率,并通过上采样和特征融合逐层细化体素特征。
- 输出:生成高分辨率的 TSDF,表示最终的 3D 场景结构。
Fragment Posed Images(分段的带位姿的图像输入)
NeuralRecon 的输入是视频中的图像序列以及相机的位姿信息。具体的输入包括:
彩色图像:单目视频中的每一帧图像。
相机位姿:包括相机的内参和外参(位姿信息),这些信息帮助网络确定每个图像在三维空间中的位置。
这些图像会被分成多个 batch,每个 batch 表示一个 fragment(片段)。在每个片段中,网络从多视角的图像推断深度图并估计局部 TSDF(Truncated Signed Distance Function)。然后,通过将多个局部 TSDF 融合,逐步构建出完整的 3D 场景。
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 # 返回输出和损失字典
最关键的一步:反向投影计算体素特征
现在已经知道了图片的特征,以及拍摄图片的内外参数,通过计算反向投影可以将建立的每一个体素投影到每张图片中的某一个位置,对每张图片提取特征,相加再求平均值,得到最后的特征值。
投影公式
公式引用论文: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 # 返回特征体积和每个体素被观察到的次数
总结
在每一个细化的阶段,体素的特征都会进行跨层次的融合,使得最终生成的 TSDF 在细节层次上更加精确。网络在不同分辨率下进行多视角图像特征的融合,确保体素的特征一致性。通过三层的计算和逐层细化,NeuralRecon 最终生成一个高分辨率的 TSDF,这个 TSDF 值可以用来表示重建的三维场景。最后的 TSDF 是通过逐步改进与融合特征得到的,它捕捉了场景的几何细节与深度信息。生成的 TSDF 值可以通过软件(如 Meshlab)进行可视化,展示最终重建的三维场景。该场景通过融合多张图像和相机位姿信息,在三维空间中呈现出连贯且细节丰富的模型。