摘要: 线性整数规划;提取邻接关系;能量项设置及累计;构建方程约束;重建模型。
历经点云聚类、多边形网络重建等处理,手中这花花绿绿的候选面片数据还剩一个主要的处理步骤:线性整数规划。以我从运筹学/最优化理论那粗浅借来的理解,这个过程将在某些硬约束条件下,以最小化能量函数中能量项的加权和为目标,通过求解器的解算来得出无边界、闭合流型的三维模型。
线性规划之胡言乱语 还是更具体地拆解下这个过程中的思路吧。为描述面片选取过程,以集合中的面要素和边要素为变量,构建二元的混合整数线性规划问题,感觉就是把多边形网络中的面和线想象成方程中的变量x、y,设置多个包含不同x和y的方程构成方程组?线性规划就像是求解出其中最想要的(多个)x和y值。 怎么形容呢,以上面的一个三角锥几何体为例,这是很久之前我测试ply文件读写功能时绘制的,它由4个面和6条边(/12条半边)构成,相当于这个几何体包含了4个x、6个y。这些变量是已知的全部条件,把它们全部丢给求解器后,为了让它返回一个完整闭合的几何体而不是分散的面/线,就需要设置称为“条件约束”的额外要求。一个较容易理解的约束是要求每条线y必须与两个面x相连,即连续闭合的几何体中每个面都是两两相接的。在当前的这个三角锥中很容易能靠这个约束得到唯一解,毕竟唯一符合该要求的x、y组合只有一种情况。倘若把这个几何体沿中间腰部横切一刀,得到一个小三角锥和梯形体,满足这个约束条件的就有两组解,这时候就要引入“能量函数”或者应该说是“目标函数”的概念,它应该是通过给每个x/y赋予不同的属性值,比方说我期望得到的几何体还是三棱锥,那在求解前我会对所有面x做一次三角面/四角面检测,给“三角面”赋一个较高的属性值,“四角面”赋一个较低的属性值,当解算器筛选出了两组(或更多)解后,进行能量函数求解的过程,将每组xy的属性值累加起来,取值最大的那组解为输出结果。 那么这些便是我对线性规划概念的理解了,不是很严谨肯定是有问题的,欢迎斧正及补充。主要还是自己怠惰了,没认真找过这方面的系统性学习资料,仅看到过的两份基础知识入门[24] 、[25] 也是一头雾水,以后有兴趣了再翻翻看吧(笑)。
PolyFit的线性规划术语 胡扯了这么久,也该来看看论文中关于这部分的阐述。文中作者设置了一个硬约束来保证模型的闭合性,见下图的闭合模型硬约束公式,第一条的等式左侧变量是所有的边ei连接的面数量,讲人话来表述是它要求所有被选取的边只能连接两个面片,或所连接面片能构成更大的多边形(合并后所接的面数为0),从而使选取的边均位于闭合多边形上而不是外凸面。但如前所述,仅在该约束下候选平面集内存在多种符合条件的组合,为求得最优结果,PolyFit算法基于面片支撑点数量、面片面积和点覆盖面积这三类面片属性构建优化问题的能量函数,并通过能量最小化过程得到最优解。其中的最小化过程可以理解为,每个面片根据上述参数赋予一个值,在筛选出满足约束的所有组合后,取累计值最小的组合为规划问题的解。 因此,如何定义合理的能量函数也是该方法的关键。PolyFit算法的能量函数由这三个能量项构成: 数据拟合项评估由点簇所拟合平面的置信度,该值根据候选面内的平面点距及点与面的法向量差值来确定,可反映对应点云的平坦度及信噪比。 模型复杂性项定义为模型中锐利边缘的比例。二元函数𝑐𝑜𝑟𝑛𝑒𝑟根据相连面属性决定边e是否为锐边,由于面片均以两两相交分割得到,非孤立边与面片的所有拓扑关系如下图所示,图b、c表示边能将相连面组合为一个更大的多边形,𝑐𝑜𝑟𝑛𝑒𝑟值为1;图d到g中的边可表示为模型边界,𝑐𝑜𝑟𝑛𝑒𝑟值为0。 点覆盖项指模型中未覆盖点云的面片占比,它根据点簇在拟合面上的面积与拟合面总面积比值来确定。
面片最优重建的整体流程 终于,理论部分基本总结完毕,进入代码解析环节。有关面片最优重建的源码封装在ReconstructProcess文件内,代码的原始版本来自Polygonal_surface_reconstruction/polyfit_example_model_complexity_control.cpp,网址[14] 往下翻的3.3章节便能看到,作者列出了好3种能构成不同表面复杂度的指导参数,这些参数是上面提及的能量项系数,我这里设置的值与论文建议值稍微有点不同:数据拟合项wt_fitting = 0.43,模型复杂性项wt_complexity = 0.30,点覆盖项wt_coverage = 0.27,论文建议值是 0.46,0.27,0.27,相比起来相当于把拟合项的权重匀了点给复杂性项,可能是当时的我更看重模型的细节吧,印象中复杂性项的权重越高那期望的模型锐边数目/局部细节会更多。 主程序中对该部分的调用代码如下:
1 2 3 4 5 ReconstructProcess obj_recon; CGAL::Surface_mesh<Kernel::Point_3> mesh_model; obj_recon.Reconstruct (mesh_candidate, mesh_model, obj_hp.GetTriplet (), 0.43f , 0.27f , 0.30f );
基于上一大节中的候选面集mesh_candidate,函数Reconstruct()将对其进行邻接信息提取、设置约束条件、构建约束方程和求解方程4步,将得到的最优面集组合存入到model中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 bool ReconstructProcess::Reconstruct (Polygon_mesh & mesh_candidate, Polygon_mesh & model, const Planes_intersections & map_planes_intersection, double wt_fitting, double wt_coverage, double wt_complexity) { CGAL::Timer timer; timer.start (); if (mesh_candidate.num_faces () < 4 ) { std::cout << "错误:至少需要4个候选表面才能重建出紧密模型,当前的表面集仅有 " + std::to_string (mesh_candidate.num_faces ()) + " 个面" ; return false ; } InitParameter (wt_fitting, wt_coverage, wt_complexity); GetAdjacency (mesh_candidate, map_planes_intersection); if (_isUseRoofObj) { DefineConstraintFactors_v2 (mesh_candidate); DefineConstraintFactor_TopDis (mesh_candidate); } else DefineConstraintFactors (mesh_candidate); BuildConstraint (mesh_candidate); SolveConstraint (mesh_candidate, model); timer.stop (); std::cout << "重建模型的面数为 " << model.faces ().size () << " 个,运行耗时 " << timer.time () << " 秒" << std::endl; return true ; }
邻接信息提取 第一步是把候选面集的半边分类为交叉边和边缘边,便于确定所选边、面的关系。 该小节的代码实现在函数ReconstructProcess::GetAdjacency(),其中2部分的内容没太弄懂其中逻辑,大概是先取候选面集内的每个半边,依据前一节的三重交点集合map_planes_intersection快速定位出两个端点的坐标,以两个端点坐标为键、半边下标为值存入到哈希表face_pool。但是,我要说但是,代码里写了很多断言CGAL_assertion(),看似是做了些判断筛选,但这些都是debug用的,除非前面有些步骤导致数据出现缺失不然都不会触发,真正做了判断的仅两个:去除边界半边(只与一个面相接)、调整端点顺序,况且已知mesh中点下标的情况下可以直接拿取对应的点坐标,感觉根本不需要传入map_planes_intersection来获取。不过倒也体现了CGAL的严密性,如果前面搞了些删属性的骚操作到这一步就会跑不通。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 typename Polygon_mesh::template Property_map<Polygon_mesh::Vertex_index, std::set<const Kernel::Plane_3*> > vertex_supporting_planes = mesh_candidate.template property_map<Polygon_mesh::Vertex_index, std::set<const Kernel::Plane_3*> >("v:supp_plane" ).first; typedef typename std::unordered_map<const Kernel::Point_3*, std::set<Polygon_mesh::Halfedge_index> > Edge_map;typedef typename std::unordered_map<const Kernel::Point_3*, Edge_map > Face_pool;Face_pool face_pool; for (auto h : mesh_candidate.halfedges ()){ Polygon_mesh::Face_index f = mesh_candidate.face (h); if (f == Polygon_mesh::null_face ()) continue ; Polygon_mesh::Vertex_index sd = mesh_candidate.source (h); Polygon_mesh::Vertex_index td = mesh_candidate.target (h); const std::set<const Kernel::Plane_3*>& set_s = vertex_supporting_planes[sd]; const std::set<const Kernel::Plane_3*>& set_t = vertex_supporting_planes[td]; CGAL_assertion (set_s.size () == 3 ); CGAL_assertion (set_t .size () == 3 ); std::vector<const Kernel::Plane_3*> s_planes (set_s.begin(), set_s.end()) ; CGAL_assertion (s_planes[0 ] < s_planes[1 ]); CGAL_assertion (s_planes[1 ] < s_planes[2 ]); const Kernel::Point_3* s = map_planes_intersection[s_planes[0 ]][s_planes[1 ]][s_planes[2 ]]; std::vector<const Kernel::Plane_3*> t_planes (set_t .begin(), set_t .end()) ; CGAL_assertion (t_planes[0 ] < t_planes[1 ]); CGAL_assertion (t_planes[1 ] < t_planes[2 ]); const Kernel::Point_3* t = map_planes_intersection[t_planes[0 ]][t_planes[1 ]][t_planes[2 ]]; if (s > t) std::swap (s, t); face_pool[s][t].insert (mesh_candidate.halfedge (f)); }
第3部分则把face_pool换了个结构体Intersection转存到std::vector _adjacency。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 struct Intersection : public std::vector<Polygon_mesh::Halfedge_index>{ const Kernel::Point_3* s; const Kernel::Point_3* t; }; for (Face_pool::const_iterator it = face_pool.begin (); it != face_pool.end (); ++it){ const Kernel::Point_3* s = it->first; const Edge_map& tmp = it->second; typename Edge_map::const_iterator cur = tmp.begin (); for (; cur != tmp.end (); ++cur) { const Kernel::Point_3* t = cur->first; const std::set<Polygon_mesh::Halfedge_index>& faces = cur->second; Intersection fan; fan.s = s; fan.t = t; fan.insert (fan.end (), faces.begin (), faces.end ()); _adjacency.push_back (fan); } }
设置约束条件 先看DefineConstraintFactors()中的内容,这里面封装的是原PolyFit的约束方案。第1部分是常规的变量声明,提一嘴题外话,更老练的变量声明应该放在头文件中类的私有变量那,函数用到的时候再给它定义/初始化/分配内存,印象中PolyFit的源码也是这么写的,当初大概是为了理清算法里面的过程,把这些变量的声明定义绑到一起塞到各个函数内做封装,现在回看有点吃力不讨好。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 typename Polygon_mesh::template Property_map<Polygon_mesh::Face_index, std::size_t > face_num_supporting_points = mesh_candidate.template property_map <Polygon_mesh::Face_index, std::size_t >("f:num_supporting_points" ).first; typename Polygon_mesh::template Property_map<Polygon_mesh::Face_index, FT> face_areas = mesh_candidate.template property_map <Polygon_mesh::Face_index, FT>("f:face_area" ).first; typename Polygon_mesh::template Property_map<Polygon_mesh::Face_index, FT> face_covered_areas = mesh_candidate.template property_map <Polygon_mesh::Face_index, FT>("f:covered_area" ).first; typename Polygon_mesh::template Property_map<Polygon_mesh::Face_index, std::size_t > face_indices = mesh_candidate.template add_property_map <Polygon_mesh::Face_index, std::size_t >("f:index" ).first; double total_points = 0.0 ;std::size_t idx = 0 ; for (auto f : mesh_candidate.faces ()){ total_points += face_num_supporting_points[f]; face_indices[f] = idx; ++idx; } std::size_t num_faces = mesh_candidate.number_of_faces (); std::size_t num_edges (0 ) ;
第2部分,计算一些中间变量,主要是为交叉边索引edge_usage_status和锐利边索引edge_sharp_status取值,以此设置线性规划求解器MIP_Solver的变量对象variables。这里作者设置约束的变量总个数为候选面数+两倍交叉边数,每个变量为二进制类型只可取值0或1。这段代码对应图2硬约束的第二个式子,其中,每个变量取二进制好理解,1和0表示在提取最优面集的过程中,是否保留该候选面/边,比较摸不着头脑的是它的变量总数是num_faces + num_edges * 2 而不是 num_faces + num_edges,问了下ai猜测原因如下: (1)每个候选面对应一个二进制变量(数量为num_faces),用于决定该面是否被选中。 (2)每个边需要两个变量进行约束,第一个为保留状态变量(数量为num_edges),决定该边是否保留;第二个为锐/交叉边状态变量(数量为num_edges),用于判断该边是否为交叉边。
1 2 3 private : std::unordered_map<const Intersection*, std::size_t > edge_usage_status; std::unordered_map<const Intersection*, std::size_t > edge_sharp_status;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 for (std::size_t i = 0 ; i < _adjacency.size (); ++i){ const Intersection& fan = _adjacency[i]; if (fan.size () == 4 ) { std::size_t var_idx = num_faces + num_edges; edge_usage_status[&fan] = var_idx; ++num_edges; } } std::size_t total_variables = num_faces + num_edges + num_edges; variables = solver.create_variables (total_variables); for (std::size_t i = 0 ; i < total_variables; ++i){ MIP_Solver::Variable* v = variables[i]; v->set_variable_type (MIP_Solver::Variable::BINARY); }
第3部分,设置线性目标函数对象objective,它的类型(目标)为线性最小化,这意味着在整个混合整数规划(MIP)问题中,求解器将会寻找一组变量取值,使得目标函数的值尽可能低。这块值得注意的点是对权重系数的再次计算,在我摘抄的代码中拟合项的权重不变,余下两项乘上点云的总点数,其中点覆盖项再除以包围盒表面积(area(M)),复杂度项除以非边界的半边数量(|E|)。它们俩的除数处理对应能量函数项公式中各自的分子项;乘以总点数的理由按作者在GitHub上的答疑[26] ,是为了将权重系数从[0,1.0f]的范围映射到[0,total_points],以避免浮点数的精度损失。 (注:我不太确定当时摘抄是否改动了权重系数的内容,应该是没有的没那么扎实的理论储备我不敢乱调,且github上对形类似式的权重系数代码,作者的答疑是“所有三个权重都简单地乘以总点数”,或许拟合项的权重系数_wt_fitting在别的地方做了“乘以总点数”的处理)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 std::vector<Kernel::Point_3> vertices (mesh_candidate.number_of_vertices()) ;idx = 0 ; for (auto v : mesh_candidate.vertices ()){ vertices[idx] = mesh_candidate.points ()[v]; ++idx; } const Kernel::Iso_cuboid_3& box = CGAL::bounding_box (vertices.begin (), vertices.end ());FT dx = box.xmax () - box.xmin (); FT dy = box.ymax () - box.ymin (); FT dz = box.zmax () - box.zmin (); FT box_area = FT (2.0 ) * (dx * dy + dy * dz + dz * dx); double coeff_data_fitting = _wt_fitting;double coeff_coverage = total_points * _wt_coverage / box_area;double coeff_complexity = total_points * _wt_complexity / double (_adjacency.size ());MIP_Solver::Linear_objective * objective = solver.create_objective (MIP_Solver::Linear_objective::MINIMIZE);
余下的一小部分内容,是对线性目标函数objective中的每个变量variables累计能量项值,然后设置约束条件MIP_Solver::Linear_constraint* c。代码中每个variables累计能量项的过程为:找到非边界半边集合_adjacency中的所有锐边,属于锐边的变量累加一个复杂度项的值coeff_complexity;遍历候选面集mesh_candidate中的每个面片,按面下标找到对应的variables并累计数据拟合项的值: 负的复杂度项权重*该面片的整成点数量。支撑点数量的计算过程可跳转到compute_confidences.h的Candidate_confidences::compute查阅(201-202行),负号意味着支持点越多,拟合能量越低,从而鼓励目标函数选取支持点数更多的面; 最后一个点覆盖项,先计算每个面的未覆盖面积(面片的总面积减去已覆盖面积),并以coeff_coverage为权重将该值累计进目标函数的MIP变量。这一项的目的是惩罚未被覆盖的区域,从而引导最终模型更好地覆盖点云数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 std::size_t num_sharp_edges = 0 ; for (std::size_t i = 0 ; i < _adjacency.size (); ++i){ const Intersection& fan = _adjacency[i]; if (fan.size () == 4 ) { std::size_t var_idx = num_faces + num_edges + num_sharp_edges; edge_sharp_status[&fan] = var_idx; objective->add_coefficient (variables[var_idx], coeff_complexity); ++num_sharp_edges; } } CGAL_assertion (num_edges == num_sharp_edges);for (auto f : mesh_candidate.faces ()){ std::size_t var_idx = face_indices[f]; std::size_t num = face_num_supporting_points[f]; objective->add_coefficient (variables[var_idx], -coeff_data_fitting * num); double uncovered_area = (face_areas[f] - face_covered_areas[f]); objective->add_coefficient (variables[var_idx], coeff_coverage * uncovered_area); }
最后为了满足闭合网格要求(内部边恰好被两个面共享),添加约束条件MIP_Solver::Linear_constraint* c确保“与边关联的面数必须为2或0”。在面变量部分,对fan中每个候选面,将对应的面变量以系数1添加到约束中,这样约束项累加了fan内所有面的变量;边变量部分,若为内部交叉边(fan.size() == 4,fan由四个面构成),取出对应的边变量并添加系数-2。由此构成的约束表达式如下所示: 由于边界边不属于封闭模型的一部分,若该变量为边界边,则不对其累计额外的约束值。(注:这里add_coefficient的对象为线性约束变量c,上面的add_coefficient对象为线性目标函数objective,注意累计值的区分)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 std::size_t var_edge_used_idx = 0 ; for (std::size_t i = 0 ; i < _adjacency.size (); ++i){ MIP_Solver::Linear_constraint* c = solver.create_constraint (0.0 , 0.0 ); const Intersection& fan = _adjacency[i]; for (std::size_t j = 0 ; j < fan.size (); ++j) { Polygon_mesh::Face_index f = mesh_candidate.face (fan[j]); std::size_t var_idx = face_indices[f]; c->add_coefficient (variables[var_idx], 1.0 ); } if (fan.size () == 4 ) { std::size_t var_idx = num_faces + var_edge_used_idx; c->add_coefficient (variables[var_idx], -2.0 ); ++var_edge_used_idx; } else { } }
设置额外的能量项 首要说明的是,代码中的DefineConstraintFactors_v2()与DefineConstraintFactor_TopDis()现在看来均是有问题的。DefineConstraintFactors_v2()相较于DefineConstraintFactors()少了尺度处理部分,本意是想直接按归一化的权重来累计objective中的能量值,但佘略的步骤有两个隐患:一是代码与能量项公式不符,每个项的系数均少了分母项;二是如[26] 所说会有潜在的精度损失。而另一个函数DefineConstraintFactor_TopDis()是我对修改能量公式的一次失败尝试,从最终结果上看,顶部表面凹凸不平坑坑洼洼而不是更丰富、规整的细化结构,或许是权重分配的不合理/未设置额外的条件约束。 不管怎么说,还是把这一坨保留下来存档了,属于是相当简陋了(笑)。代码中先取出指向MIP求解器的线性对象指针*objective,为mesh_candidate中的所有面对象累计值_wt_roof * dist_p2boxTop,_wt_roof是预设的权重系数,dist_p2boxTop是该面片到包围盒顶部的归一化距离值,详见上一大节的“添加额外的平面属性”,理论上会让求解器更倾向于选取靠近顶部的面(这类variables累计的值相对而言更小)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 void ReconstructProcess::DefineConstraintFactor_TopDis (const Polygon_mesh & mesh_candidate) { typename Polygon_mesh::template Property_map<Polygon_mesh::Face_index, double > face_roof = mesh_candidate.template property_map <Polygon_mesh::Face_index, double >("f:dist_boxTop" ).first; typename Polygon_mesh::template Property_map<Polygon_mesh::Face_index, std::size_t > face_indices = mesh_candidate.template property_map <Polygon_mesh::Face_index, std::size_t >("f:index" ).first; MIP_Solver::Linear_objective * objective = solver.objective (); for (auto f : mesh_candidate.faces ()) { std::size_t var_idx = face_indices[f]; double dist_p2boxTop = face_roof[f]; objective->add_coefficient (variables[var_idx], _wt_roof * dist_p2boxTop); } }
这部分默认是不使用的,可将_isUseRoofObj修改为true来启用。
构建约束方程 这一部分的内容那会翻译错名字了,应该是“补充约束”更贴合些,其核心目的是为候选面片mesh_candidate中那些与锐边相关的对象添加约束,从而保证在重构过程中如果某个边被认定为锐边,那么其相邻的面必须满足一定的选择条件,避免出现不连续或不合理的表面。 代码封装在ReconstructProcess::BuildConstraint(),代码解析大多借助GPT。松弛因子M用于构造带有条件逻辑的线性约束,使得约束可以用线性表达式表示。然后是按照不等式“X[var_edge_usage_idx] - X[var_edge_sharp_idx] >= 0”构建约束,它表明如果一个边被标记为锐边(X[var_edge_usage_idx] = 1),则它一定要被选中(X[var_edge_sharp_idx] = 1),以保持约束成立。若边没有被使用,则无论其是否为锐边,这个约束都会自动满足(可能此时约束的值为0,大概)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 void ReconstructProcess::BuildConstraint (const Polygon_mesh & mesh_candidate) { typename Polygon_mesh::template Property_map<Polygon_mesh::Face_index, const Kernel::Plane_3*> face_supporting_planes = mesh_candidate.template property_map <Polygon_mesh::Face_index, const Kernel::Plane_3*>("f:supp_plane" ).first; typename Polygon_mesh::template Property_map<Polygon_mesh::Face_index, std::size_t > face_indices = mesh_candidate.template property_map <Polygon_mesh::Face_index, std::size_t >("f:index" ).first; double M = 1.0 ; for (std::size_t i = 0 ; i < _adjacency.size (); ++i) { const Intersection& fan = _adjacency[i]; if (fan.size () != 4 ) continue ; MIP_Solver::Linear_constraint* c = solver.create_constraint (0.0 ); std::size_t var_edge_usage_idx = edge_usage_status[&fan]; c->add_coefficient (variables[var_edge_usage_idx], 1.0 ); std::size_t var_edge_sharp_idx = edge_sharp_status[&fan]; c->add_coefficient (variables[var_edge_sharp_idx], -1.0 ); } }
内层的双重循环是为每对邻接关系的相关面添加额外约束,取当前邻接fan关联的所有面两两组合,外层循环的为f1内层循环为f2,判断组合的这两个面是否共面,若对应的支撑面face_supporting_planes不同则不共面,则两个面在几何上存在不连续的可能,需进一步设置约束。该约束的表达式见注释部分,其中,为满足锐边的约束(X[var_edge_sharp_idx] = 1的情况),要求该边相关的四个面至少有两个面被选中。代码的写法应该是在该约束c中,所选的边、面变量的累加值不小于1 - 3M。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 for (std::size_t j = 0 ; j < fan.size (); ++j){ Polygon_mesh::Face_index f1 = mesh_candidate.face (fan[j]); const Kernel::Plane_3* plane1 = face_supporting_planes[f1]; std::size_t fid1 = face_indices[f1]; for (std::size_t k = j + 1 ; k < fan.size (); ++k) { Polygon_mesh::Face_index f2 = mesh_candidate.face (fan[k]); const Kernel::Plane_3* plane2 = face_supporting_planes[f2]; std::size_t fid2 = face_indices[f2]; if (plane1 != plane2) { c = solver.create_constraint (1.0 - 3.0 * M); c->add_coefficient (variables[var_edge_sharp_idx], 1.0 ); c->add_coefficient (variables[fid1], -M); c->add_coefficient (variables[fid2], -M); c->add_coefficient (variables[var_edge_usage_idx], -M); } } }
综上,BuildConstraint()额外设置了两条约束,第一条约束:若一条边为锐边,则该边必须被激活使用;第二条约束:锐边的邻近关系中必须有两个面被选择,来确保网络的表面整体连续。
约束方程求解 第四部分,在列好线性规划问题的变量对象及约束条件后,调用MIP求解器解出候选网络的最优组合,进而生成三维模型。实现代码封装在ReconstructProcess::SolveConstraint()中,首先,先调用求解器的solve()方法,只有在求解器成功找到一个满足所有约束的解时,才对候选网格进行处理。
1 2 3 4 5 6 7 if (solver.solve ()){ } else std::cerr << "solving the binary program failed" << std::endl;
求解完毕后,将所有变量(面、边的选择、尖锐边状态)的值用solver.solution()取出并存入到解向量X中,这些解应该是0或1的二元解。然后, 遍历候选面集的下标并从X中取相应的值,若其值四舍五入后为0,说明这个面在解中没有被选中,将其记录到删除列表to_delete。接着调用CGAL::Euler::remove_face()方法,根据to_delete中面的半边索引移除mesh_candidate所有未被选中的面。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 typename Polygon_mesh::template Property_map<Polygon_mesh::Face_index, std::size_t > face_indices = mesh_candidate.template property_map <Polygon_mesh::Face_index, std::size_t >("f:index" ).first; const std::vector<double >& X = solver.solution ();std::vector<Polygon_mesh::Face_index> to_delete; std::size_t f_idx (0 ) ;for (auto f : mesh_candidate.faces ()){ if (static_cast <int >(std::round (X[f_idx])) == 0 ) to_delete.push_back (f); ++f_idx; } for (std::size_t i = 0 ; i < to_delete.size (); ++i){ Polygon_mesh::Face_index f = to_delete[i]; Polygon_mesh::Halfedge_index h = mesh_candidate.halfedge (f); CGAL::Euler::remove_face (h, mesh_candidate); }
还有一小部分内容是更新面集的锐边信息,对mesh_candidate中的边增加一个属性”e:sharp_edges”,然后标记MIP解中满足锐边条件的边,设该属性值为true,方便后续处理中对这些边做特殊处理(提取边界特征、细化锐角等)。本程序后续未有效利用该属性,可视情况注释掉。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 typename Polygon_mesh::template Property_map<Polygon_mesh::Edge_index, bool > edge_is_sharp = mesh_candidate.template add_property_map <Polygon_mesh::Edge_index, bool >("e:sharp_edges" ).first; for (auto e : mesh_candidate.edges ()) edge_is_sharp[e] = false ; for (std::size_t i = 0 ; i < _adjacency.size (); ++i){ const Intersection& fan = _adjacency[i]; if (fan.size () != 4 ) continue ; std::size_t idx_sharp_var = edge_sharp_status[&fan]; if (static_cast <int >(X[idx_sharp_var]) == 1 ) { for (std::size_t j = 0 ; j < fan.size (); ++j) { Polygon_mesh::Halfedge_index h = fan[j]; Polygon_mesh::Face_index f = mesh_candidate.face (h); if (f != Polygon_mesh::null_face ()) { std::size_t fid = face_indices[f]; if (static_cast <int >(std::round (X[fid])) == 1 ) { Polygon_mesh::Edge_index e = mesh_candidate.edge (h); edge_is_sharp[e] = true ; break ; } } } } }
此时的mesh_candidate便是最终的网络模型,调用CGAL::copy_face_graph将其复制到model中,它反映了求解器给出的最佳选择结果。 OK兄弟们,PolyFit的整个流程总算走完一遍了,传入的点云数据历经区域聚类、平面拟合、两两相交细分、线性规划求解后,终于生成了一整块闭合且贴近原始表面的多边型网络,真是可喜可贺可口可乐~~不过现在它这个花花绿绿的样子还有待简化合并下,下一节再整理整理模型的面片合并和模型导出方法。
参考文献&引用 [1] https://3d.bk.tudelft.nl/liangliang/ [2] https://3d.bk.tudelft.nl/liangliang/publications/2017/polyfit/polyfit.html [3] https://www.youtube.com/watch?v=_0brfDFkIkc [4] Linfu X ,Han H ,Qing Z , et al. Combined Rule-Based and Hypothesis-Based Method for Building Model Reconstruction from Photogrammetric Point Clouds [J]. Remote Sensing, 2021, 13 (6): 1107-1107. [5] Nan L , Wonka P .PolyFit: Polygonal Surface Reconstruction from Point Clouds[C]//International Conference on Computer Vision.IEEE, 2017. [6] https://github.com/Kitware/CMake/releases/download/v3.29.3/cmake-3.29.3-windows-x86_64.zip [7] https://boostorg.jfrog.io/artifactory/main/release/1.85.0/source/boost_1_85_0.7z [8] https://github.com/CGAL/cgal/releases/download/v5.6.1/CGAL-5.6.1.zip [9] https://gitlab.com/libeigen/eigen/-/archive/3.4.0/eigen-3.4.0.zip [10] https://scipopt.org/download.php?fname=SCIPOptSuite-9.0.0-win64-VS15.exe [11] https://zenodo.org/records/4390295#.Y0eIodJBxuV [12] https://www.zhihu.com/question/277599635/answer/2149719454 [13] https://zhuanlan.zhihu.com/p/668272208 [14] https://doc.cgal.org/latest/Polygonal_surface_reconstruction/index.html#Chapter_PolygonalSurfaceReconstruction [15] https://zhuanlan.zhihu.com/p/90858099 [16] https://zhuanlan.zhihu.com/p/668272208 [17] https://doc.cgal.org/latest/HalfedgeDS/index.html [18] https://segmentfault.com/q/1010000043819426 [19] https://doc.cgal.org/latest/BGL/group__PkgBGLEulerOperations.html#gaa386d0cdef3b5d6ef43d6b503392dbcd [20] https://doc.cgal.org/latest/Shape_regularization/index.html [21] https://doi.org/10.3390/rs13061107 [22] https://doi.org/10.1016/j.isprsjprs.2022.09.017 [23] https://blog.csdn.net/u013279723/article/details/106265948 [24] https://www.gurobi.com/resources/mixed-integer-programming-mip-a-primer-on-the-basics/ [25] https://docs.mosek.com/modeling-cookbook/mio.html [26] https://github.com/LiangliangNan/PolyFit/issues/30