本片文章算是作为上一篇文章【G2O与多视角点云全局配准优化】 的延续。
从上篇文章杠已知,现在目前手头已经有了配准好的全局点云、每块点云的变换矩阵以及对应每块点云的RGB图像。接下来,自然而然的便是:对全局点云重建三维网格,并对重建后的三维网格进行纹理贴图。
已知:三维模型(三角面片,格式任意),多视角RGB图片(纹理)、变换矩阵;求:为三维模型贴上纹理图。
针对上述待求解纹理,熟悉的小伙伴肯定直到,其实在求解多视角立体匹配的最后一步--表面重建与纹理生成。
接上篇,为完成自己的目的,首先需要重构三角网格,从点云重建三角网格,与相机、纹理图片等没有任何关系,可直接使用pcl中的重建接口或者cgal甚至meshlab等软件,直接由三维点云重建三角网格。
在完成重建三角网格之前,还有很重要--但是可以酌情省略的一个关键步骤:多视角三维点云融合!下面继续使用自己之前所拍摄的8个点云片段为例进行示例记录。(注意,此处强调8个点云片段,并不表示8个视角的点云,后续说明)。
多视角点云融合
将多个点云片段拼接之后,无法避免的存在多个视角下点云相互重叠的情况。如下图所示,在重叠区域的点云疏密程度一般都大于非重叠区域点云。进行点云融合的目的一是为了点云去重,降低点云数据量;二是为了进一步平滑点云,提高精度,为后续的其他计算(如测量等)提供高质量的数据。
恰巧,自己对移动最小二乘算法有一定了解,在pcl中,N年之前【pcl之MLS平滑】和【pcl之MLS算法计算并统一法向量】也做过一些小测试。在我的印象中,移动最小二乘是这样工作的:针对深入数据,计算目标点及其邻域,这些待计算的局部数据便构成了目标点的紧支域,在紧支域内有某个函数对目标点进行运算,运算的基本规则是依据紧支域内其他点到目标点的权重不同,这里的某个函数即所谓的紧支函数。紧支函数 + 紧支域 +权重函数构成了移动最小二乘算法的基本数学概念,所谓移动性则体现在紧支域在允许的空间中“滑动”计算,直至覆盖所有数据。最小二乘一般针对全局求最优,而移动最小二乘由于其 “移动性”(紧支)不仅能针对全局优化求解,而且也具有局部优化性,更进一步的,针对三维点云,其能够提取等值面,结合MC(移动立方体)算法,实现表面三角网格的重建。
pcl中,关于MLS算法的例子很多很多,自己之前的两篇水文也算也有个基本的应用介绍,此处不在过多记录。在这里,直接使用如下代码:
pcl::PointCloud mls_points; //存结果
pcl::search::KdTree::Ptr tree(new pcl::search::KdTree);
pcl::MovingLeastSquares mls;
mls.setComputeNormals(false);
mls.setInputCloud(res);
mls.setPolynomialOrder(2); //MLS拟合的阶数,
mls.setSearchMethod(tree);
mls.setSearchRadius(3);
mls.setUpsamplingMethod(pcl::MovingLeastSquares::UpsamplingMethod::VOXEL_GRID_DILATION);
mls.setDilationIterations(0); //设置VOXEL_GRID_DILATION方法的迭代次数
mls.process(mls_points);
注意,上述计算过程中,使用了pcl::MovingLeastSquares::VOXEL_GRID_DILATION
方法,按照pcl官方解释,该方法不仅能够修复少量点云空洞,而且能够对点云坐标进行局部优化,输出为全局疏密程度一样的点云,通过设置不同的迭代次数,该方法不仅能够降采样
,还能上采样
(主要通过迭代次数控制).
将自己的数据放入上述计算过程,点云融合局部效果如下:
肉眼可见点云更加均匀、平滑,同同时数据量从100w+ 降到 10w+.
上述操作,基本满足了自己下一步的要求。
进一步的,鉴于表面重建并非重点,针对上述结果,这里直接使用MeshLab软件中的泊松重建,结果记录如下:
多视角纹理映射
在这里,正式引入自己对所谓“多视角”的理解。
首先,多视角与多角度,通常情况下,这两个概念基本都是在说同一件事情,即从不同方位角拍摄三维模型。但是这其中又隐含两种不同的 [操作方式],其一如手持式三维扫描仪、SLAM中的状态估计、甚至搭载摄像机/雷达的自动驾驶汽车等,这些场景下基本都是相机在动,目标不动,即相机绕目标运动;其二 如转台式三维扫描仪等,这些场景基本都是相机不动,目标动,即目标本身具有运动属性。所以这里,有必要对多视角与多角度做进一步的区分,多视角指的是相机的多视角(对呀,相机才具有真实的物理视角、位姿、拍摄角度 巴拉巴拉...),多角度指的是目标物体的不同角度(对呀,一个物体可以从不同的角度被观察)。
其次,外参,外参是很重要的一个概念(废话,还用你说!),可真的引起其他使用者足够的重视吗?未必! 我们一般常说的外参,其实是带有主语的,只是我们太习以为常从而把主语省略了。外参---一般指相机的外参,即描述相机的变化。在多视角三维点云配准中,每个点云都有一个变化矩阵,该变化矩阵可以称其为点云的外参,即描述点云的变化。至此,至少出现了两个外参,且这两个外参所代表的物理意义完全不一样,但是又有千丝万缕的联系。我们都知道,宏观下运动是相互的,则必然点云的外参和对应相机的外参互逆。
注: 之所以这里对上述概念做严格区分,主要还是因为自己之前对上述所提到的概念没有真正深入理解,尤其是对外参的理解,导致后期算法计算出现错误;其二还是后续库、框架等对上述有严格区分。
OpenMVS与纹理映射
终于到了OpenMVS。。。。
已知OpenMVS可以用来稠密重建(点云)、Mesh重建(点云-->网格)、Mesh优化(非流行处理、补洞等)、网格纹理映射,正好对应源码自带的几个APP。
此处,目的只是单纯的需要OpenMVS的网格纹理映射功能!浏览国内外有关OpenMVS的使用方法,尤其国内,基本都是”一条龙“服务,总结使用流程就是 colmap/OpenMVG/visualSFM...+ OpenMVS,基本都是使用可执行文件的傻瓜式方式(网上一查一大堆),而且基本都是翻来覆去的相互”引用“(抄袭),显然不符合自己要求与目的。吐个槽。。。
为了使用Openmvs的纹理映射模块,输入数据必须是 .mvs
格式文件,额。。。.mvs
是个什么鬼,我没有啊,怎么办?!
好吧,进入OpenMVS源码吧,用自己的数据去填充OpenMVS所需的数据接口。(Window10 + VS2017+OpenMVS编译省略,主要是自己当时编译的时候没做记录,不过CMake工程一般不太复杂)。
场景Scene
查看OpenMVS自带的几个例子,发现其必须要填充Scene
类,针对自己所面对的问题,Scene
类的主要结构如下:
class MVS_API Scene
{
public:
PlatformArr platforms; //相机参数和位姿 // camera platforms, each containing the mounted cameras and all known poses
ImageArr images; //纹理图,和相机对应 // images, each referencing a platform's camera pose
PointCloud pointcloud; //点云 // point-cloud (sparse or dense), each containing the point position and the views seeing it
Mesh mesh; //网格 // mesh, represented as vertices and triangles, constructed from the input point-cloud
unsigned nCalibratedImages; // number of valid images
unsigned nMaxThreads; // maximum number of threads used to distribute the work load
... //省略代码
bool TextureMesh(unsigned nResolutionLevel, unsigned nMinResolution, float fOutlierThreshold=0.f, float fRatioDataSmoothness=0.3f, bool bGlobalSeamLeveling=true, bool bLocalSeamLeveling=true, unsigned nTextureSizeMultiple=0, unsigned nRectPackingHeuristic=3, Pixel8U colEmpty=Pixel8U(255,127,39));
... //省略代码
}
自己所需要的主要函数为bool TextureMesh()
,可见其自带了很多参数,参数意义后期使用时再讨论。
Platforms
首先来看platforms
,这个东西是Platform
的数组。在OpenMVS中,Platform
定义如下:
class MVS_API Platform
{
...
public:
String name; // platform's name
CameraArr cameras; // cameras mounted on the platform
PoseArr poses;
...
}
对我们而言,必须需要填充CameraArr
和PoseArr
两个数组。
CameraArr
是CameraIntern
类型的数组。CameraIntern
是最基本的相机父类,一提到相机,则必须包含两个矩阵:内参和外参,CameraIntern
也不例外,它需要使用如下三个参数填充,其中K
为归一化的3X3相机内参,可用Eigen
中的矩阵填充,所谓归一化,其实就是该内参矩阵的每个元素除以纹理图片的最大宽度或高度;R
顾名思义,表示相机的旋转,C
表示相机的平移,R和C共同构成了相机的外参。
KMatrix K; //相机内参(归一化后的) the intrinsic camera parameters (3x3)
RMatrix R; //外参:相机旋转 rotation (3x3) and
CMatrix C;
PoseArr
是Pose
类型的数组。Pose
是类Platform
中定义的一个结构体:
struct Pose {
RMatrix R; // platform's rotation matrix
CMatrix C; // platform's translation vector in the global coordinate system
#ifdef _USE_BOOST
template
void serialize(Archive& ar, const unsigned int /*version*/) {
ar & R;
ar & C;
}
#endif
};
typedef CLISTDEF0IDX(Pose,uint32_t) PoseArr;
从上述Pose
中可以看出,其也包含了两个矩阵,但是这里的Pose
中的矩阵所表示的物理意义和CameraIntern
中外参的物理意义完全不同,简单来说,CameraIntern
中表示的是相机本身固有或自带的属性,而Pose
则表示整个Platform
平台(包含相机的)在世界坐标系中位姿矩阵,针对每一张纹理图(下面介绍),这两个属性参数共同构成了对应相机的真实外参(后期通过源代码解释)【问题1】。
Images
images
是ImageArr
类型的数组,如同源码中解释,每个Image
对应于每个相机位姿。Image
的结构如下:
class MVS_API Image
{
public:
uint32_t platformID;//plateform相对应的ID // ID of the associated platform
uint32_t cameraID; // camer对应的ID //ID of the associated camera on the associated platform
uint32_t poseID; // 位姿ID //ID of the pose of the associated platform
uint32_t ID; // global ID of the image
String name; // 该imgage的路径 // image file name (relative path)
Camera camera; // view's pose
uint32_t width, height; // image size
Image8U3 image; //load的时候已经处理. image color pixels
ViewScoreArr neighbors; // scored neighbor images
float scale; // image scale relative to the original size
float avgDepth;
....
}
如上述自己添加的中文注释,其中platformID、cameraID和poseID
三个参数一定要与Platform
中的成员属性严格对应,对自身而言这是最重要的三个参数,Platform
中的成员变量本身并不携带ID属性,只是根据添加顺序默认排序,ID索引从0开始递增。
在Image
结构中,还有不得不提的camera
成员,如源码中所注释,它表示视角(Camera)的位姿,也就是哪个相机对应于该图片。乍一看该camera
成员也必须要进行填充,可事实并非如此,原因后期解释【问题2】。
该结构中其他成员,如图像宽度高度、图像的name等,在调用Image::LoadImage(img_path);
函数的的时候会自动填充;neighbors
成员表示图像于3D点的邻接关系,在纹理贴图中非必要选项,而且不影响纹理贴图效果。
PointCloud
该成员表示点云,一般作为OpenMVS的稀疏重建或稠密重建的结果,对于网格不具备约束性,直接舍弃不填充。
Mesh
OpenMVS中的三角网格存储结构,我只需要知道其有Load函数即可。