Monodepth2 Paper Note

Abstract: Per-pixel ground truth depth data is challenging to acquire at scale. To overcome this limitation, self-supervised learning has emerged as a promising alternative for training models to perform monocular depth estimation. In this paper, we propose a set of improvements, which together result in both quantitatively and qualitatively improved depth maps compared to competing self-supervised methods.

Research on self-supervised monocular training usually explores increasingly complex architectures, loss functions, and image formation models, all of which have recently helped to close the gap with fully-supervised methods. We show that a surprisingly simple model, and associated design choices, lead to superior predictions. In particular, we propose (i) a minimum reproduction loss, designed to robustly handle occlusions, (ii) a full-resolution multi-scale sampling method that reduces visual artifacts, and (iii) an auto-masking loss to ignore training pixels that violate camera motion assumptions. We demonstrate the effectiveness of each component in isolation, and show high quality, state-of-the-art results on the KITTI benchmark.

1. 先验知识

对于自监督或者说无监督学习的单目图像深度估计任务而言,存在两种现有的训练方案。

  1. stereo pairs 即立体图像对,包含一张左边照相机的图片和一张右边照相机的图片。
  2. monocular video 即单目的视频流数据。

对于monocular video来说,为了估计图像的深度,由于缺乏相机运动的先验知识,所以模型同样需要估计相机的姿态变化。这通常会额外训练一个叫作 pose estimation network_ 的网络,即姿态估计网络。网络的输入是一系列的图像帧,网络的输出是对应的相机变换方式。

什么是 ill-posed 问题?不适定问题(ill-posed problem)和适定问题(well-posed problem)是数学领域对问题进行定义的术语。不满足以下三点的任意一点,都是ill-posed问题:

  1. A solution exists. 有解
  2. The solution is unique. 解唯一
  3. The solution's behavior changes continuously with the initial conditions. 解稳定

为什么深度估计是ill-posed问题? 因为深度估计对于每一张图片会有多个解,且不稳定。

Occluded pixels:遮盖的像素点。在某些序列图下,会出现在一张图没有被遮挡,而在另一张图被遮挡的像素点。

Out of view pixels:出界的像素点。由于相机的运动,导致某些像素点不在另一张图像上。

2. 本文的主要贡献

  1. 当使用单目监督时,会产生像素遮盖的现象。为解决这一问题,提出了外观匹配损失函数
  2. 提出了自动掩码的方法,可以忽略那些和相机运动无关的像素点。
  3. 提出了多尺度的外观匹配损失函数,可以降低深度的暇疵。

3. 方法

3.1 自监督学习

自监督深度估计将学习问题作为一种视图合成问题,即通过一个其他视角的图片来预测目标图片的样子。使用一个中间变量——视差或深度,来限制网络在图像合成任务的学习过程,最后我们就可以提取出这个中间变量,转化成图像的深度图片。这是一个ill-posed的问题,因为如果确定了相机的相对姿态,会有很多图片(图片中的每个像素的深度都不一致)都可以合成出对应视角下的目标图片。经典的双目甚至多目方法通过强化深度图片的平滑度以及计算图片的一致性解决了这个问题。

该工作仍继续沿用之前的思想,将任务作为视图合成问题,通过目标图像和重建得到的目标图像之间的误差作为学习的指导。

首先,可以将源图像与目标图像 之间相机的相对姿态表示为。通过预测目标图像的深度图,最小化目标图像以及不同的源图像重建出的目标图像之间的误差,实现深度的预测。数学描述为

这里的可以进一步拆解为如下公式

那么如何理解公示(3)呢?

第一行的disparity就是Network预测的结果。首先,我们需要对disparity做一个转化。由先验知识可知 可见depth和disparity呈现反比关系。这里使用DispToDepth函数实现转换。

第二行做了一个前提假设,假设我们拍摄target图像的相机在世界坐标系的原点处。根据《视觉SLAM十四讲》第五讲中所述,三维世界的坐标系可以通过相机的内参矩阵转化为二维坐标。数学描述为 其中P为世界坐标,u、v为相机坐标。而我们现在有了像素的二维坐标,可以通过np.meshgrid构建。为了得到对应的世界坐标,我们不仅仅需要像素的二维坐标,还需要一个深度。其实,公式(5)可以写成 称为归一化坐标。归一化坐标可以看成相机前方处平面上的坐标。可以看到,如果对归一化坐标同时乘以任何一个数,相机的归一化坐标是完全一样的,说明深度信息在单目图像上丢失了。

深度就是需要通过公式(3)第一行得到的depth了。首先用np.meshgrid得到像素坐标,和内参的逆矩阵相乘得到归一化坐标,归一化坐标乘以深度,就可以得到三维世界坐标系下的坐标(注意这里假设我们拍摄target图像的相机就是世界坐标系),即CamPoints。数学描述为 我们现在有了世界坐标了,接下来让我们移动相机,假设我们简单的把相机平移到原相机位置的右侧,这时候就可以用先验知识求得相机位姿的矩阵。当然,更细节的得到位姿矩阵的方法在transformation_from_parameters函数中实现,需要结合轴角平移向量构建。接下来是理解模型是如何监督的重点。我们通过左侧相机的像素坐标得到了每一个像素位置的世界坐标。现在我们需要知道,左侧图像中的每一个像素点所对应在空间中的实际的点,映射到了右侧相机的图片的哪个像素位置。可以继续通过公式(5)变换到像素坐标系下,但这里的右侧相机所在的坐标系已经与标准的世界坐标系有所偏移,所以我们需要做一些小小的修改

具体在Project3D中实现。这里得到的新的,就是左侧图像像素所表示的空间点,在右侧图像像素的位置,即右侧图像的处。之后通过sampler采样,得到根据右侧图像重建后的图像

结合图1更好理解

Fig. 1 监督过程

对于重建误差,可以表述为 边缘平滑损失定义为 最后总的目标函数为

3.2 自监督学习优化

Fig. 2 (a)深度预测网络。(b)相机位姿网络。(c)最小重建投影误差。(d)多尺度估计。

3.2.1 逐像素最小重建投影误差

Problematic pixels可以分为两类,一种是超出图像边界的像素(Out of view pixels),另一种是遮挡像素(Occluded pixels)。超出图像边界的像素可以通过掩盖这些像素的误差,即不计入误差累计。但并没有解决遮挡像素的问题。

计算重建投影误差的时候,之前的一些方法都是把不同源图像与目标图像的误差平均。这种情况下,如果网络预测出目标图像的某一个像素点A正确的深度,经过源图像的采样后,重建出的像素点可能会像图2的(c)的所示,导致采样到了遮挡像素的部位,从而造成对应位置像素值差别很大,对结果造成一定的影响。所以相较于公式(1),该工作做了如下改进 Screenshot 2022-11-16 at 13.00.20

Fig. 3 最小重建投影误差。每个像素都会根据其最匹配的源图像进行计算。图中L画圈部位在R中属于遮挡像素,但可以在-1中找到相匹配的像素点。本质而言是充分的利用了不同源图像的信息。

这种改进可以将超出边界的像素和遮挡像素问题一举解决,且可以见效图片边界的瑕疵、提升遮挡边界的清晰度并且可以提高精度。

3.2.2 静态像素自动掩码

自监督学习的一个前提假设是,场景是静止的,相机是运动的。当相机是静止的或场景中有运动的物体时,性能就会受到很大的影响(测试时会产生黑洞)。一个很简单的想法就是,把这一帧到下一帧中不变的像素点掩盖。同于先前的工作,也是将每个像素点加入掩码算子。但不同的是,先前工作需要通过学习得到,而该工作是通过前向传播过程自动计算得到,且只有0和1两个值。观察得到,如果在相邻两帧中像素点保持相同会有三种情况:第一种是相机静止;第二种是物体和相机保持同样的速度和方向,相对静止;第三种是低纹理区域。

3.2.3 多尺度估计

之前工作的多尺度估计都是在不同size之下计算好误差,最后平均。而这样会倾向于在大面积的low-texture区域产生黑洞,也会造成瑕疵。因此该工作将不同size的预测的图片resize到原始图片的大小,在相同的尺度下进行计算。

3.3 其他考虑

网络的baseline采用U-net的encoder,decoder架构,加入了跳层连接,以便更好的结合深度特征信息和局部特征。使用ResNet18作为encoder,包含11M参数量,并且采用了ImageNet上预训练好的权重,实验表明预训练的结果要比从一开始训练的结果要好。网络的decoder采用和Unsupervised monocular depth estimation with left- right consistency中的类似,但最后一层加入sigmoid输出,其他采用ELU作为激活函数。Decoder中用反射padding代替zero-padding,实验表明效果不错。

对于姿态网络,网络输出轴角和平移向量,并缩放0.01。

数据增强的概率为50%,策略为水平翻转、随机亮度、对比度、饱和度以及hue jitter。所有输入网络的图片都会用相同的参数进行增强。

网络用pytorch实现,优化器为Adam,epoch为20,batchsize为12,输入输出默认为640x192。前15epoch用0.0001学习率,最后五个为0.00001。平滑为0.001。

3.4 数据集

4. 源码解析

4.1 layers.py

layers.py文件是Monodepth2中最为核心的一个文件,其中包含了以下几种函数:

disp_to_dpeth(disp, min_depth, max_depth):它会将网络的simoid输出转化为预测的深度,这里是运用了深度和视察的先验关系。

transformation_from_parameters(axisangle, translation, invert):根据poseNet预测出的角度和平移量,计算4x4的转换矩阵。

rot_from_axisangle(vec):根据坐标轴的欧拉角,得到4x4的旋转矩阵。

get_translation_matrix(translation_vector):把预测出的平移量转化为4x4的平移矩阵。

upsample(x):将输入的张量用最邻近差值实现上采样。

get_smooth_loss(disp, img):计算视差图的平滑度。

compute_depth_errors(gt, pred):计算预测出的深度图片和GT的各项衡量指标的值。

包含以下层:

Conv3x3:3x3卷积计算单元。

BackprojectDepth:根据预测的深度、相机坐标系下的坐标和相机内参矩阵的逆矩阵,计算空间坐标系的矩阵(4维度,最后一维度为1,表示三维空间的点)。

Project3D:根据转换矩阵T和相机内参矩阵K,以及三维空间坐标,计算得到对应相机坐标系下的坐标。

SSIM:结构相似性计算层。

disp_to_depth

def disp_to_depth(disp, min_depth, max_depth):
    """Convert network's sigmoid output into depth prediction
    The formula for this conversion is given in the 'additional considerations'
    section of the paper.
    """
    # 将预测得到的视差通过min_depth和max_depth的限制,得到对应范围内的深度图
    # TODO: we know that disp = f*b / depth, but in this function, where are f and b?
    min_disp = 1 / max_depth
    max_disp = 1 / min_depth
    scaled_disp = min_disp + (max_disp - min_disp) * disp
    depth = 1 / scaled_disp
    return scaled_disp, depth

transformation_from_parameters

def transformation_from_parameters(axisangle, translation, invert=False):
    """Convert the network's (axisangle, translation) output into a 4x4 matrix
    	 一般而言,对于一个坐标,可以通过旋转矩阵R和平移向量t来变换到另一个坐标
    	 但是也可以将R和t写作齐次式,M
    	 函数的输入是欧拉角,需要调用 rot_from_axisangle将欧拉角转化为旋转矩阵
    	 另一个输入是平移向量,需要调用get_translation_matrix将向量转化为平移矩阵
    	 最后将两个矩阵结合即可
    """
    R = rot_from_axisangle(axisangle)
    t = translation.clone()

    if invert:
        R = R.transpose(1, 2)
        t *= -1

    T = get_translation_matrix(t)

    if invert:
        M = torch.matmul(R, T)
    else:
        M = torch.matmul(T, R)

    return M

rot_from_axisangle

def rot_from_axisangle(vec):
    """Convert an axisangle rotation into a 4x4 transformation matrix
    (adapted from https://github.com/Wallacoloo/printipi)
    Input 'vec' has to be Bx1x3
    """
    angle = torch.norm(vec, 2, 2, True)
    axis = vec / (angle + 1e-7)

    ca = torch.cos(angle)
    sa = torch.sin(angle)
    C = 1 - ca

    x = axis[..., 0].unsqueeze(1)
    y = axis[..., 1].unsqueeze(1)
    z = axis[..., 2].unsqueeze(1)

    xs = x * sa
    ys = y * sa
    zs = z * sa
    xC = x * C
    yC = y * C
    zC = z * C
    xyC = x * yC
    yzC = y * zC
    zxC = z * xC

    rot = torch.zeros((vec.shape[0], 4, 4)).to(device=vec.device)

    rot[:, 0, 0] = torch.squeeze(x * xC + ca)
    rot[:, 0, 1] = torch.squeeze(xyC - zs)
    rot[:, 0, 2] = torch.squeeze(zxC + ys)
    rot[:, 1, 0] = torch.squeeze(xyC + zs)
    rot[:, 1, 1] = torch.squeeze(y * yC + ca)
    rot[:, 1, 2] = torch.squeeze(yzC - xs)
    rot[:, 2, 0] = torch.squeeze(zxC - ys)
    rot[:, 2, 1] = torch.squeeze(yzC + xs)
    rot[:, 2, 2] = torch.squeeze(z * zC + ca)
    rot[:, 3, 3] = 1

    return rot

get_translation_matrix

def get_translation_matrix(translation_vector):
    """Convert a translation vector into a 4x4 transformation matrix
    """
    T = torch.zeros(translation_vector.shape[0], 4, 4).to(device=translation_vector.device)
		
    # 转为列向量
    t = translation_vector.contiguous().view(-1, 3, 1)

    T[:, 0, 0] = 1
    T[:, 1, 1] = 1
    T[:, 2, 2] = 1
    T[:, 3, 3] = 1
    # 给T矩阵最后一列的前三个赋值为列向量t
    T[:, :3, 3, None] = t

    return T

get_smooth_loss

def get_smooth_loss(disp, img):
    """Computes the smoothness loss for a disparity image
    The color image is used for edge-aware smoothness
    """
    # 计算x方向的视差的梯度
    grad_disp_x = torch.abs(disp[:, :, :, :-1] - disp[:, :, :, 1:])
    # 计算y方向的视差的梯度
    grad_disp_y = torch.abs(disp[:, :, :-1, :] - disp[:, :, 1:, :])
		
    grad_img_x = torch.mean(torch.abs(img[:, :, :, :-1] - img[:, :, :, 1:]), 1, keepdim=True)
    grad_img_y = torch.mean(torch.abs(img[:, :, :-1, :] - img[:, :, 1:, :]), 1, keepdim=True)

    grad_disp_x *= torch.exp(-grad_img_x)
    grad_disp_y *= torch.exp(-grad_img_y)

    return grad_disp_x.mean() + grad_disp_y.mean()

BackprojectDepth

class BackprojectDepth(nn.Module):
    """将预测得到的depth图转化为3维的点云图片
    """
    def __init__(self, batch_size, height, width):
        super(BackprojectDepth, self).__init__()

        self.batch_size = batch_size
        self.height = height
        self.width = width
				
        # 根据宽度和高度,生成对应的行列坐标,会得到[列坐标2维矩阵,行坐标2维矩阵]这样一个list
        meshgrid = np.meshgrid(range(self.width), range(self.height), indexing='xy')
        # 把list按照第一维度堆叠起来,生成shape为[2, width, height]的id_coords
        self.id_coords = np.stack(meshgrid, axis=0).astype(np.float32)
        self.id_coords = nn.Parameter(torch.from_numpy(self.id_coords),
                                      requires_grad=False)

        self.ones = nn.Parameter(torch.ones(self.batch_size, 1, self.height * self.width),
                                 requires_grad=False)
				
        # 将id_coords的列坐标和行坐标先打平为[1, width*height],堆叠为[2, width*height],扩充为[1,          				 # 2, width*height]
        self.pix_coords = torch.unsqueeze(torch.stack(
            [self.id_coords[0].view(-1), self.id_coords[1].view(-1)], 0), 0)
        # 按照batch_size堆叠
        self.pix_coords = self.pix_coords.repeat(batch_size, 1, 1)
        # 将1张量与坐标结合,这样形成[1, 3, width*height]的张量,每一列就代表一个[x, y, 1]二维坐标
        self.pix_coords = nn.Parameter(torch.cat([self.pix_coords, self.ones], 1),
                                       requires_grad=False)

    def forward(self, depth, inv_K):
        """
            u   fx  0   cx     X/Z
            v = 0   fy  cy  .  Y/Z
            1   0   0   1      1

            pix_coords = K . cam_points
            cam_points = K-1 . pix_coords
        """
        cam_points = torch.matmul(inv_K[:, :3, :3], self.pix_coords)
        # 上一行得到的cam_points,本质上是在归一化平面上的,此时Z即深度信息是丢失的,这也是
        # 单目图像无法得到3维图像的原因。但这里的depth是经过神经网络预测得到的,因此对于归一化
        # 平面上的坐标[X/Z, Y/Z, 1]同时乘各个点的深度,就能得到[X, Y, Z]
        cam_points = depth.view(self.batch_size, 1, -1) * cam_points
        # [X, Y, Z] -> [X, Y, Z, 1]
        cam_points = torch.cat([cam_points, self.ones], 1)

        return cam_points

Project3D

class Project3D(nn.Module):
    """Layer which projects 3D points into a camera with intrinsics K and at position T
    """
    def __init__(self, batch_size, height, width, eps=1e-7):
        super(Project3D, self).__init__()

        self.batch_size = batch_size
        self.height = height
        self.width = width
        self.eps = eps

    def forward(self, points, K, T):
        # 传入的points为世界坐标系下的坐标[X, Y, Z, 1]
        # K为相机内参,T为转换矩阵
        M = torch.matmul(K, T)[:, :3, :]
				
        # 相机坐标系下的坐标 = K (RP+t) = KTP
        cam_points = torch.matmul(M, points)

        # 除去Z,eps是为了防止除0导致的错误
        pix_coords = cam_points[:, :2, :] / (cam_points[:, 2, :].unsqueeze(1) + self.eps)
        # 从[batch, 2, width*height]转换为[batch, 2, height, width]
        pix_coords = pix_coords.view(self.batch_size, 2, self.height, self.width)
        # [batch, 2, height, width] -> [batch, height, width, 2]
        pix_coords = pix_coords.permute(0, 2, 3, 1)
        # 归一化到0-1之间
        pix_coords[..., 0] /= self.width - 1
        pix_coords[..., 1] /= self.height - 1
        # 移动到[-1, 1]之间
        pix_coords = (pix_coords - 0.5) * 2
        return pix_coords