目录
背景知识
一、什么是3D Gaussian
相信大家都听说过高斯分布。高斯分布,也叫正态分布,是一种非常常见的概率分布。如果你熟悉一维的高斯分布,可以直接跳到本章的第1节。如果你不了解高斯分布也没有关系,可以看一下下面这张图(图片来自搜狗百科):
这就是一张高斯分布的图片。图中,表示均值,表示标准差。简单来说,假设我有一个变量,它可能是任何一个实数。现在,我们假设它遵循上图这个高斯分布,那么上图中函数值越高的地方,就越有可能取到这个数值。我们可以看到,函数值最高的地方是,也就是说的值最有可能是。离越远的地方,取到这个数值的可能性越低。有多低呢?这是由决定的。从图中可以看出,的值有68.27%的概率位于到之间,有95.45%的概率位于到之间,有99.73%的概率位于到之间。可以说,越大,就越有可能取到一个离较远的值。和是高斯分布的两个参数,它们决定了高斯分布的形状。
如下是高斯分布的公式:
了解了一维的高斯分布,我们就来看一下3D Gaussian,也就是3D高斯。为了描述一个3D高斯,我们需要四个属性:位置
、协方差矩阵
、透明度
和球谐系数
。
1. 位置
首先是位置,也就是一个3D高斯在三维空间中的位置,与1D高斯的均值对应。不同的是,我们需要三个数值才能表示三维空间中的一个位置。因此,我们可以用一个包含三个元素的向量来表示3D高斯的位置,其中、、分别表示3D高斯在x轴、y轴和z轴上的坐标。
2. 协方差矩阵
所谓的协方差矩阵其实就相当于是1D高斯的标准差。一个3D高斯的协方差矩阵是一个3行3列的矩阵:
有的朋友可能要问了:既然1D高斯只用了一个数就表示了高斯分布的分散程度,为什么3D高斯足足要用九个数呢?这是因为一个3D高斯在x轴、y轴和z轴上可能有不同的分散程度。比如以下这两张图:
图中越黄、越亮的位置表示概率越高,越紫越暗的地方表示概率越低。两张图中,3D高斯的均值都位于原点,左图中,3D高斯在x轴、y轴、z轴上的方差均为1,而右图中,3D高斯在x轴和z轴上的方差为1,在y轴上的方差为2。很明显,右图中的3D高斯在垂直方向上更加分散,呈现出一个椭圆的形状。因此可以看出,单独一个数值不足以表达3D高斯的分散程度,我们需要至少三个数,来表示三个方向上不同的分散程度。这三个数就构成了协方差矩阵对角线上的三个数。
这时可能又有朋友要问了,那如果x轴、y轴和z轴分别有三个不同的方差,使用这三个数还不足以表达一个3D高斯的分散程度吗?遗憾的是,确实不能。大家可以看出,上面两张图中的3D高斯都是关于x轴和y轴轴对称的。如果我想创建一个斜着的、不关于x轴或y轴轴对称的3D高斯,那仅有三个变量是不够的。因此,我们需要引入协方差。
那什么是协方差呢?我们可以把协方差看作一个用于表示两个变量之间相关性的数值。如果两个变量之间的协方差为正,那么这两个变量是正相关的,也就是说,如果其中一个变量增加,那么另外一个变量也会增加;反之,如果两个变量之间的协方差为负,那么这两个变量就是负相关的,当其中一个变量增加时,另外一个会减少。上面的两张图中,两个3D高斯所有方向上的协方差均为0。为了更好地看出协方差能够带来的变化,我们可以看下面这两张图:
如图,左侧是一个x轴和z轴上的方差为1、y轴上方差为2、所有协方差均为0的3D高斯,而右侧在左侧的基础上,将x轴和y轴之间的协方差变为1。明显可以看出,由于协方差的引入,椭圆变得倾斜了。因此可以证明,我们的确需要不止三个数才能有效地表示一个3D高斯。
不过其实我们也并不需要多达九个数值,只需要六个就够了。这是因为x轴与y轴之间的协方差和y轴与x轴之间的协方差本质上是一个数,也就是说和是相等的。之所以使用九个数,是因为九个数可以组成一个3×3的矩阵,更加便于运算。
需要注意一下,协方差矩阵有一个非常重要的性质,那就是协方差矩阵一定是半正定的。什么是半正定呢?半正定矩阵的定义是:如果矩阵是半正定矩阵,那么对于任意实非零列向量,都有。至于这具体是什么意思,以及怎么证明,其实在三维重建中不是特别重要,感兴趣的朋友可以自己去查一下,不过你需要记住协方差矩阵有这么个性质,后面会用到。
这里也给感兴趣的朋友们放一下三维高斯分布的公式,和前面一维的一样,你不需要记住或者看懂它。
3. 透明度与球谐系数
这两个属性都不是3D高斯本身带有的属性,所以放在一起说。透明度很好理解,就是描述这个3D高斯有多透明。透明度只包含一个数值。值得注意的是,3D高斯的透明度不止受透明度影响,概率密度越高的地方就越不透明,概率密度越低就越透明。由于高斯分布的概率密度在靠近中心的位置最高,因此每个3D高斯最中心是不透明度最高的。具体可以看一下这篇文章的封面图:
可以看出图中共有三个3D高斯,每个3D高斯在中心位置色彩最亮、最鲜艳,而到边缘位置会逐渐发黑。这是因为3D高斯在边缘位置的不透明度较低,露出了黑色的背景。
重点在于这个球谐系数。球谐系数可以理解为用于表示颜色的一组数值。我们通常在表示颜色时使用三个数值,分别表示红光、绿光和蓝光的强度。而如果使用球谐系数来表示颜色,每个3D高斯需要多达48个数值。至于为什么使用这么多数值,简单来说是因为球谐系数不止能够表达单一的颜色,而是能表达物体表面不同位置的不同色彩和纹理,表达能力要远远强于用RGB表达的颜色。至于球谐系数到底是什么,这个比较难解释清楚。我打算之后单独出一篇文章来解释,等文章写好之后会把链接放在这里,大家可以关注我一下,这样写好的时候文章就会第一时间推到你那里。不过即使你只用RGB三个数值来表达颜色,也能有不错的效果。
二、什么是splatting ?
1、来源
-
为什么叫Splatting:其实这个来源于拟声词splat,我们可以想象输入是一些雪球,而图片是一堵墙。图片生成的过程就是将雪球扔到墙面上去的过程,每次投掷都会造成扩散痕迹。
(足迹)
这是不是很像我们之前提到的3D高斯的透明度?越靠近高斯的中心,颜色就越重,不透明度越高;离中心越远,不透明度就越低,颜色就越黯淡。这也就是为什么渲染高斯的过程被称作"splatting"。如果硬要翻译的话,可以把splatting翻译成"喷溅"。
-
为了将3D高斯渲染成一张二维的图像,我们需要将它们投影到二维平面上,因此需要将它们转换为2D的高斯。splatting本质上就是把3D的高斯转换成2D的过程。这个过程主要分三步:第一,确定2D高斯的位置;第二,确定2D高斯的协方差矩阵;第三,根据3D高斯的球谐系数和透明度计算2D高斯的颜色。
2、定义
- 一种
体渲染
的方法:将数据或图像通过一定的算法投影到二维或三维空间中,也就是从3D物体渲染到2D平面。 - 而在nerf中使用的体渲染方法是Ray-casting,是被动的计算出每个像素点受到发光粒子的影响来生成图像,也就是主角是
像素
- Splatting是通过将每个体素(3D像素)转化为一个小的平面形状(通常是圆形或椭圆形), 主动计算出每个发光粒子如何影响像素点。主角是
粒子
。
3、核心
-
(1)选择【雪球】
为什么使用雪球 ?
因为输入是一些点云中的一些点,他是没有体积的,我们要对其进行膨胀,就要选择一些核
,这些核可以是高斯、圆、正方形。但一般来说选择的是高斯核,这也是有原因的:
接下来,选定了高斯核
又该如何控制这个他的影响区域(也就是这个椭球)的形状呢?
答案是通过我们上文提到的协方差矩阵。
那又该如何控制协方差矩阵呢?
答案是通过旋转和缩放矩阵来控制协方差。
至此我们已经制造好了一个"雪球"。
-
(2)抛掷雪球,得到足迹
所谓的抛掷雪球得到足迹的过程就是从3D到像素的过程。
首先我们要了解一个CG中常用的一种变换:
它的基本逻辑就是上面四个过程:
1.观测变换
通俗来说就是从世界坐标系到相机坐标系到一个过程, 它的本质来说就是上文中介绍到的仿射变换(w=Ax+b )。
如下图所示,一个高斯球我们从不同的角度来看就会得到不同的结果。
- 投影变换
在已经变换到相机角度后,我们要做的就是要把这个物体映射到成像平面上。这个方法分为两个,也就是下图中的正交投影(right)和透视投影(left)。
正交投影
它是与深度没有关系的,也就是不存在远小近大。从图中可以直观的看到,红球和黄球是一样大的,用数学语言来说就是与Z无关,这个特性也使得它的速度是比较快的。它的大致流程如下:
将一个立方体
平移到坐标原点,在对其进行缩放变为一个一定大小的正方体
。变换公式如下:
前半部分为缩放,后半部分为平移。
透视投影
他的过程因为考虑到近大远小的原则,也更加符合我们人眼的观感。这也使得过程稍微复杂了一些。
如上图,我们可以简单的想象一下,将我们的视锥尾部压扁,就能得到一个正交投影第一步的立方体,在对其重复相同的变换就可以将其最终映射到成像平面上。
细心的读者可能能注意到,在进行"压扁"后,原本的直线就变成了曲线,线性关系变成了非线性,这也导致了透视投影是一个非仿射变换的过程。
但其实我们是希望对高斯椭球进行仿射变换的,所以我们在这里进行存疑,后面在进行解决。
3.视口变换
在完成了投影变换后,我们就得到了一个范围在「1,1」的正方体小块。
因为在上一步中我们将原本的立方体压缩成了这个正方体,他的h和w的比例也发生了变化。在这一步把这个立方进行拉伸,返回到最初的h*w的图片大小,最终到像素平面上。
4.光栅化
这一步解决的是什么问题呢?
因为我们的物体本质是连续的,但是屏幕则不然,它是由一个个离散的发光管。所以我们光栅化就是要把连续的转换成离散的。使用到的方法也很简单,就是采样:
- 加以合成,形成最后的图像
在了解完了这个常用变换后,接下来我们就要回归3dgs本身来看看,落实到3dgs后的整个过程是什么样子的。
对于3D高斯来说,其实只用抓住它的两个核心点,一个是均值,一个是他的协方差矩阵即可。
1、观测变换
在世界坐标系下的描述如下:
整个变换过程本质上来说还是一个仿射变化的,所以经过变化后,得到的相机坐标系下的描述,如下图:
2、投影变换
3D高斯的投影变换采用的就是我们上文所提到的先对视锥进行压缩,再将其进行仿射变换的这么一个过程。
但与此同时,在上文中我们提到了一个问题,这个投影变换的过程是非线性的。这对于3D高斯来说是不可以接受的,为什么这么说呢?我们可以观察到透视投影的变换矩阵是x=m(t),对于均值来说,你可以做如此的变化,对于一个点来说,是没有办法发生形变的。但是但对于协方差矩阵,非线性的变化会使其中的线条发生形变造成结果的不准确。
那为了解决这个问题,我们引入了雅可比矩阵。雅克比矩阵的核心其实就是两个,一个是泰勒展开,另一个是线性逼近。
他的第一步是进行坐标变化,假设存在原坐标X和Y,对其进行如下的坐标变化:
从图像上直观的理解就是将一个横屏竖直的直角坐标系变为了弯弯曲曲的坐标系,如下图可视:
假设我们现在关注的是途中的(-2,1)这一点的周遭,他从原本的直角坐标系变换到了我们途中黄色框体的部分我们家黄色框体进行放大后发现除去该点处是线性的,它周围的变化仍是非线性的。
接下来我们采用极限逼近的一个思想,我们假设这个黄色框体不断地缩小,我们可以发现他缩小到一定程度的时候,该点周围的变化也近似的可以看作是线性的,如下图所示:
而这个对非线性变化的局部线性近似的逼近过程就是雅克比矩阵:
有了雅可比矩阵后,我们就能很从容的解决上述的问题了:将协方差矩阵中的仿射变换矩阵替换为雅可比矩阵即可。最终得到如下的表达式:
3、视口变换
视口变换主要是对高斯中心去做,与协方差矩阵是无关系的。整个过程就还是一个平移+缩放的过程,和上文中的内容一致,不做赘述。
(3)给雪球上色
直到现在为止我们已经拥有了一个雪球,我们也试图去往墙上砸了,但现在还有一个小小的问题,我们扔的雪球不应该只有白色,我们要渲染的场景是五颜六色的,那我们扔出去的雪球也应该是有其他颜色的。在前面相当于我们已经得到了雪球有多大、有多重,现在要完成的就是给雪球上色。
要解决这个问题,我们要首先了解一个概念:基函数
- 在傅立叶变换里,任何一个函数都可以分解成正弦和余弦的线性组合。简单理解就是说,一个复杂的函数我们可以用两个简单的函数进行拟合,而这两个简单函数就是所谓的基函数,如下图:
了解完基函数,我们就来看球谐函数
三、怎么获得3D高斯的信息啊
我们现在已经知道了需要用哪些信息来描述一个3D高斯。那么问题来了,怎么才能获得这些信息呢?我们前面说到,在进行三维重建时,我们需要对场景从不同角度拍摄大量照片。我们现在就要使用这些照片来获得3D高斯的信息。我们可以使用一些工具来辅助提取这些信息,比如COLMAP。COLMAP是一个非常好用的工具,我们只要输入拍摄的照片,COLMAP就可以自动为我们分析出每张照片的拍摄位置和拍摄角度。同时,COLMAP也会以点云的形式对场景进行初步的重建,并且为每个点提供位置和颜色信息,如下图所示:
如图所示,左边展示了每张图片对应的相机位置和角度、以及重建出的点云,右侧是选中的相机的一些参数。
有了点云,如何通过它们来获取3D高斯的参数呢?其实很简单,我们只要在点云中每个点的位置放置一个3D高斯就行了。3D高斯的球谐系数可以通过点的颜色计算得到,透明度直接设成1(完全不透明),而协方差矩阵直接设成单位矩阵,表示三个轴上的方差均为1,其他位置的协方差均为0:
但是显然,这样的设置未免有些过于潦草,无法表示一个精细的模型。为了使3D高斯的参数更加精确,我们需要使用梯度下降对3D高斯的参数进行更新。
具体怎么更新呢?其实也很简单。我们不是用相机拍了很多不同角度的照片吗?现在,我们随便选择其中一个相机,从这个相机的角度去观察我们刚刚创建的那堆3D高斯,随后与相机实际拍摄的图片作对比,如下:
如图,左侧是从相机的拍摄位置对场景中的3D高斯进行渲染得到的图像,而右图是实际的图像。我们可以计算这两张图像的差异有多大,一般采用L1损失。如果你不知道L1损失是什么也没有关系,其实说白了就是把两张图片对应位置的像素的颜色作差,然后把所有的差值求平均值。比如说左边这张图片有三个像素,每个像素用RGB三个数值表示,分别是、和,右边的图片也有三个像素,分别是和,那么两张图片之间的L1损失就是:
L1损失也可以用公式表示。对于两个含有个元素的数组、,它们之间的L1损失是:
很简单的公式,对吧?
既然有了损失函数,我们就可以对损失值进行反向传播,然后进行梯度下降。和一般的机器学习不一样的是,这里没有神经网络,我们要更新的参数是3D高斯的那几个参数,也就是位置、协方差矩阵、透明度和球谐系数。以上就是3D高斯的训练过程。我们可以用不同角度拍摄的照片来训练这些3D高斯,使得它们更加精细、真实。这也就是我们需要大量不同角度照片的原因。
听起来3DGS的过程到这里就结束了是不是?但是其实还有一个很严重的问题。还记不记得我们之前说过,协方差矩阵是半正定的?如果用梯度下降的方法,我们在更新协方差矩阵的时候,不能保证更新后的矩阵是半正定的。如果不能保证协方差矩阵是半正定的,那么协方差矩阵就失去了几何意义。这是我们需要避免的。
为了解决这个问题,我们需要利用矩阵的一个性质:对于任意矩阵,一定是半正定的。恰好,一个3D高斯的协方差矩阵可以被转换成它的旋转矩阵和缩放矩阵的乘积:,而刚好等于,也就是。这样一来,如果我们储存并更新和而不是直接更新协方差矩阵,就能保证不管和被更新成什么样,计算出的协方差矩阵都是半正定的。
那什么是旋转和缩放矩阵呢?我们刚刚讲协方差矩阵的时候提到,之所以3D高斯的分散程度不能用单个数值表示,就是因为3D高斯在x、y、z三个方向上可能有不同的分散程度,并且可能需要旋转和倾斜。我们可以把它拆成两部分,先用一个矩阵描述三个轴上的分散程度,这个矩阵就是缩放矩阵,相当于协方差矩阵只保留对角线上的三个数;再用另一个矩阵描述这个3D高斯的旋转,这个矩阵就是旋转矩阵。将旋转矩阵和缩放矩阵以的形式相乘,就得到了两个矩阵的叠加,也就是协方差矩阵。
那么到此为止,使用3D高斯重建三维场景的流程就结束了。我们再整体回顾一下。首先,用COLMAP这样的工具获取相机的位置和角度,并且重建出点云。随后,用点云中的点来初始化3D高斯,在每个点的位置放置一个3D高斯,用点的颜色计算它的初始球谐系数,然后随便给它一个初始的旋转矩阵、缩放矩阵和透明度,其中旋转矩阵和缩放矩阵可以用于计算协方差矩阵。最后,我们从相机的位置和角度观察场景中的3D高斯,得到一幅画面,用这个画面和相机实际拍摄的照片作对比,计算L1损失,然后反向传播、梯度下降、更新参数。
四、自适应密度控制
自适应密度控制是一个既能加快模型收敛速度又能提高模型质量的非常重要的技巧。那什么是自适应密度控制呢?我们可以看一下原论文中的这张图:
大概的意思是,假设我们要拟合这个有点像个月牙形的图案。在重建的初期,3D高斯都被初始化得比较小(也就是协方差矩阵的数值较小),因此无法表达出想要的形状。这时,我们可以对这个3D高斯进行复制,将原本的一个高斯复制为两个。两个高斯同时训练,能加快训练速度,同时也能拟合出单个高斯难以拟合的形状。到了训练后期,高斯的尺寸可能会过大,导致无法精细地拟合出这个形状。这时,我们可以将这个高斯拆分成两个较小的高斯,它们在经过训练后能够更加贴合我们想要的形状。作者设置了两个阈值,第一个用来判断一个高斯是否过小,另一个用于判断高斯是否过大。每次训练中,我们对每个高斯进行检查,如果尺寸小于第一个阈值,就将它复制;如果尺寸大于第二个阈值,就将它拆分。
大家会发现,无论是对高斯进行复制还是拆分,高斯的总数都会增加,因此训练后的模型中的高斯数量会远大于训练前的模型。这是一件好事,因为更多的高斯有能力拟合更复杂的形状,但是这也可能导致出现一些冗余的高斯,占用大量的计算资源和储存空间。
总结一下,下面是论文代码的一个简单流程图:
五、代码分析
train.py
3dgs的实战可以参考博主的另一篇文章《3dgs训练自己的数据》
运行3DGS的命令如下
python train.py -s 数据路径
这里的简写s其实就是路径
--source_path SOURCE_PATH, -s SOURCE_PATH
我们先来看他的train.py文件,首先就是看main函数啦,先设置一系列的参数
python
if __name__ == "__main__":
# Set up command line argument parser
parser = ArgumentParser(description="Training script parameters")
lp = ModelParams(parser)
op = OptimizationParams(parser)
pp = PipelineParams(parser)
parser.add_argument('--ip', type=str, default="127.0.0.1")
parser.add_argument('--port', type=int, default=6009)
parser.add_argument('--debug_from', type=int, default=-1)
parser.add_argument('--detect_anomaly', action='store_true', default=False)
parser.add_argument("--test_iterations", nargs="+", type=int, default=[7_000, 30_000])
parser.add_argument("--save_iterations", nargs="+", type=int, default=[7_000, 30_000])
parser.add_argument("--quiet", action="store_true")
parser.add_argument("--checkpoint_iterations", nargs="+", type=int, default=[])
parser.add_argument("--start_checkpoint", type=str, default = None)
args = parser.parse_args(sys.argv[1:])
args.save_iterations.append(args.iterations)
print("Optimizing " + args.model_path)
# Initialize system state (RNG)
safe_state(args.quiet)
# Start GUI server, configure and run training
network_gui.init(args.ip, args.port) #这行代码初始化一个 GUI 服务器,使用 args.ip 和 args.port 作为参数。这可能是一个用于监视和控制训练过程的图形用户界面的一部分。
torch.autograd.set_detect_anomaly(args.detect_anomaly) #这行代码设置 PyTorch 是否要检测梯度计算中的异常。
training(lp.extract(args), op.extract(args), pp.extract(args), args.test_iterations, args.save_iterations, args.checkpoint_iterations, args.start_checkpoint, args.debug_from)
# 输入的参数包括:模型的参数(传入的为数据集的位置)、优化器的参数、其他pipeline的参数,测试迭代次数、保存迭代次数 、检查点迭代次数 、开始检查点 、调试起点
# All done
print("\nTraining complete.")
然后初始化系统状态
python
# Initialize system state (RNG)
safe_state(args.quiet)
其定义如下:
python
def safe_state(silent):
old_f = sys.stdout
class F:
def __init__(self, silent):
self.silent = silent
def write(self, x):
if not self.silent:
if x.endswith("\n"):
old_f.write(x.replace("\n", " [{}]\n".format(str(datetime.now().strftime("%d/%m %H:%M:%S")))))
else:
old_f.write(x)
def flush(self):
old_f.flush()
sys.stdout = F(silent)
random.seed(0)
np.random.seed(0)
torch.manual_seed(0)
torch.cuda.set_device(torch.device("cuda:0"))
这段代码定义了一个函数 safe_state(silent)
,该函数的作用是在执行期间重定向标准输出(sys.stdout
)到一个新的类 F
的实例。这个类 F
在写入时会检查是否需要在每行结尾处添加时间戳,以及是否需要替换换行符。
具体来说,函数的实现步骤如下:
- 将原始的标准输出保存在
old_f
变量中。 - 定义一个名为
F
的新类,该类具有以下方法:__init__(self, silent)
:初始化方法,接受一个参数silent
。write(self, x)
:写入方法,检查silent
属性,如果不是静默模式,则在每行结尾添加当前时间戳,并将文本写入原始标准输出。flush(self)
:刷新方法,将原始标准输出的缓冲区刷新。
- 创建
F
类的实例并将其赋值给sys.stdout
,从而重定向标准输出到新的类实例。 - 设置随机种子以确保结果的可重复性。
- 最后,将 PyTorch 的随机种子设置为 0,并将当前 CUDA 设备设置为 "cuda:0"(如果可用的话)。
这段代码的目的是为了在执行过程中控制标准输出的行为,添加时间戳并在需要时禁止输出,以便在某些场景下更方便地进行调试和记录。
(应该就是输出一些系统的状态的)
然后就是启动GUI以及运行训练的代码