孔洞修补算法
一、算法概述
1.1 算法名称与定位
算法名称:基于网格邻域和CGAL三维几何库的三角网格孔洞修补算法
算法类型:孔洞检测 + 环带构造 + 三角化 + 各向同性重网格(Hole Detection + Ring Construction + Triangulation + Isotropic Remeshing)
1.2 算法解决的问题
三维重建过程中,由于以下原因,网格模型通常会出现孔洞:
- 遮挡问题:拍摄时物体某些区域被其他物体遮挡,导致重建缺失
- 反射表面:玻璃、水面等高光反射区域无法被相机捕捉
- 弱纹理区域:单色墙面、地面等区域特征点不足
- 传感器噪声:深度相机的深度缺失
- 数据采集不完整:无法拍摄到的结构底部、内部等区域
孔洞的存在会影响模型的完整性、封闭性和后续使用(如3D打印、有限元分析、体积计算、纹理映射等)。
1.3 算法核心思想
算法包含两种互补的修补策略:
策略一:基于邻域特征的修补
- 对于小型孔洞,提取孔洞周围的三角面作为"引导信息"
- 构造一个环带(Ring Surface),连接孔洞边界和周围三角面
- 对环带内部的空区域进行三角化 + 细化 + 平滑,使修补区域与周边几何特征自然过渡
策略二:基于边界折线的直接修补
- 对于大型孔洞或开放性边界洞,直接使用孔洞的边界折线
- 对边界多边形进行三角化
- 再进行各向同性重网格优化三角面质量
二、算法原理与数学公式
2.1 孔洞的数学定义
在流形三角网格 M=(V,F)M = (V, F)M=(V,F) 中,孔洞(hole)定义为:
一条边 e=(vi,vj)e = (v_i, v_j)e=(vi,vj) 是边界边,当且仅当它只被一个三角面 fff 共享:
∣{f∈F:e⊂f}∣≤1|\{f \in F : e \subset f\}| \leq 1∣{f∈F:e⊂f}∣≤1
多个边界边首尾相连构成的闭合环 H=(v0,v1,...,vk−1)H = (v_0, v_1, ..., v_{k-1})H=(v0,v1,...,vk−1),其中 (vi,v(i+1) mod k)(v_i, v_{(i+1)\bmod k})(vi,v(i+1)modk) 都是边界边,构成一个孔洞。
2.2 边界与孔洞提取
半边识别
在半边数据结构中,每条边被分为两个方向相反的半边(halfedge)。边界半边是指其对面(opposite halfedge)不属于任何面的半边:
遍历网格的所有半边:
如果当前半边的对面不存在:
将该半边标记为边界半边
加入边界集合
环构建过程
将零散的边界半边连接成闭合环,是一个路径追踪过程:
1) 从边界集合中取出一条边界半边作为环的起点
2) 沿着半边的 next 指针追踪到下一条半边
- 记录当前半边的起点和终点作为环的一个顶点
- 处理共享边(当多个环共享同一条边时,跳转到共享边的对面继续追踪)
3) 重复步骤2直到回到起点,形成一个闭合环
4) 从边界集合中移除已处理的所有半边
5) 如果边界集合非空,回到步骤1继续提取下一个环
共享边处理
提取过程中的关键难点是共享边的处理。当多个环共享同一条边时(例如外轮廓和内部孔洞之间),算法需要能够正确区分。通过哈希表记录所有已遍历的边,检测到两条边具有相同的起点和终点时,标记为共享边。环追踪时遇到共享边,跳转到共享边的对面继续追踪。
2.3 基于邻域特征的孔洞修补
环带构造原理
对于小型孔洞,修补时需要利用孔洞周围的三角面来推断孔洞区域应有的几何形状。做法是在孔洞边界周围构造一个环带。
设孔洞边界由顶点序列 H={v0,v1,...,vn−1}\mathcal{H} = \{v_0, v_1, ..., v_{n-1}\}H={v0,v1,...,vn−1} 组成,以孔洞边界周围的三角面为输入信息。
切向量计算
对于孔洞边界上的每条边 (vi,vi+1)(v_i, v_{i+1})(vi,vi+1),计算其两侧三角面的切向量:
t=d2−(d2⋅d^1)d^1∥d2−(d2⋅d^1)d^1∥\mathbf{t} = \frac{\mathbf{d}_2 - (\mathbf{d}_2 \cdot \hat{\mathbf{d}}_1)\hat{\mathbf{d}}_1}{\|\mathbf{d}_2 - (\mathbf{d}_2 \cdot \hat{\mathbf{d}}_1)\hat{\mathbf{d}}_1\|}t=∥d2−(d2⋅d^1)d^1∥d2−(d2⋅d^1)d^1
其中 d^1=p1−p0∥p1−p0∥\hat{\mathbf{d}}_1 = \frac{\mathbf{p}_1 - \mathbf{p}_0}{\|\mathbf{p}_1 - \mathbf{p}_0\|}d^1=∥p1−p0∥p1−p0(边方向),d2=p2−p0\mathbf{d}_2 = \mathbf{p}_2 - \mathbf{p}_0d2=p2−p0(第三个顶点方向)。
本质上这是Gram-Schmidt正交化过程:从 d2\mathbf{d}_2d2 中减去其在 d^1\hat{\mathbf{d}}_1d^1 方向上的投影,得到垂直于边的切向分量。
三种邻域类型处理
环带构造过程中,会遇到三种不同的邻域三角形配置情况:
情况一:孤立三角形构成孔洞
当孔洞边界只有一个三角形,且该三角形的两条边都是边界边时,切向量取两条边的向量和方向,将原顶点沿此方向平移一定宽度得到环带的上层顶点。
情况二:两个相邻三角形(共享顶点)
当两个邻域三角形共享一个顶点时,切向量取共享顶点方向,将原顶点沿此方向平移。
情况三:两个不相邻三角形(通用情况)
取两个切向量的平均方向作为平移方向,使环带的方向兼顾两侧三角形的几何特征。
环带网格生成
将原边界顶点(下层)和平移后的顶点(上层)配对,构成四边形条带,然后将每个四边形分裂为两个三角形:
p1[2] -------- p1[1]
/| /|
/ | / |
p1[3] -------- p1[0] |
| p0[2] -------- p0[1]
| / | /
| / |/
p0[3] -------- p0[0]
每个四边形 (p0[i], p0[i+1], p1[i+1], p1[i]) 分裂为两个三角形:
△(p0[i], p0[i+1], p1[i])
△(p0[i+1], p1[i+1], p1[i])
孔洞识别与填充
- 提取环带网格的所有边界环
- 遍历边界环,找到与孔洞边界起始点匹配的环(即待填充的孔洞)
- 对识别出的孔洞执行三角化 + 细化 + 平滑:
- 三角化:将孔洞多边形三角化,形成初始的三角面填充
- 细化:在三角化结果中插入新顶点,使网格达到足够的密度
- 平滑:通过最小化曲率(fairing)使修补区域与周围网格平滑过渡
- 提取修补后的三角面,合并到原始网格中
2.4 基于边界折线的直接修补
孔洞多边形三角化
使用孔洞边界折线直接进行三角化,此过程将一个平面多边形剖分为若干三角形。不强制使用Delaunay三角化的原因是:当边界点分布不规则时,Delaunay约束可能导致算法陷入无限循环,使用一般三角化能够保证算法的稳定性。
目标边长计算
为后续重网格步骤计算合适的目标边长:
Ltarget=1n−1∑i=0n−2∥vi+1−vi∥2L_{target} = \sqrt{\frac{1}{n-1}\sum_{i=0}^{n-2}\|\mathbf{v}_{i+1} - \mathbf{v}_i\|^2}Ltarget=n−11i=0∑n−2∥vi+1−vi∥2
即孔洞边界顶点间的均方根边长,作为重网格的目标边长。此参数决定了重网格的精细程度------边长越小,修补网格越密。
各向同性重网格
三角化后的网格可能存在狭长三角面,需要通过各向同性重网格来优化。重网格的迭代步骤如下:
对于每次迭代:
步骤1: 分裂长边
遍历所有边,如果边长 > (4/3) × L_target
在该边的中点插入新顶点,将边一分为二
关联的三角形也随之分裂
步骤2: 塌缩短边
遍历所有边,如果边长 < (4/5) × L_target
将该边塌缩(将两个端点合并)
移除退化的三角形
步骤3: 翻转边
遍历所有内部边,计算翻转前后的角度质量
如果翻转能提高最小角或最大角的质量,则执行翻转
步骤4: 切向松弛
沿表面切向方向移动顶点位置
使顶点分布更均匀,减少网格扭曲
边界保护:重网格过程中必须保护边界轮廓。如果不保护,由于计算精度误差,修补区域的边界会与原始网格的边界产生缝隙,导致修补区域与周围网格分离。
三、完整算法流程
3.1 基于邻域特征的修补流程
输入: 三角网格 + 孔洞邻域三角形列表
输出: 修补后的三角网格
Step 1: 解析孔洞邻域三角面
├── 输入三角形列表围成孔洞边界
└── 每个三角形提供其三个顶点坐标
Step 2: 遍历相邻三角形对,生成环带上下层顶点
├── 对每对相邻三角形 (tri[i], tri[i+1])
├── 判断邻域类型(孤立三角形/共享顶点/不相邻)
├── 根据类型计算切向量方向
├── points0[i] = 原边界顶点(下层)
└── points1[i] = 沿切向量平移一定宽度(上层)
Step 3: 构造环带网格
├── 用 points0 和 points1 构成四边形条带
└── 每个四边形分裂为两个三角形
Step 4: 修补环带内部的孔洞
├── 提取环带的所有边界环
├── 找到与孔洞起始点匹配的环
├── 执行三角化 + 细化 + 平滑
└── 提取修补后的三角面
Step 5: 合并结果
├── 将修补三角面转换为目标格式
└── 合并到原始三角网格中
3.2 基于边界折线的修补流程
输入: 孔洞边界折线顶点列表
输出: 修补后的三角网格
Step 1: 数据格式转换
└── 将输入顶点转换为算法库需要的格式
Step 2: 三角化孔洞多边形
├── 对边界折线进行三角化
└── 构建临时三角网格
Step 3: 各向同性重网格优化
├── 计算目标边长(边界顶点均方根边长)
├── 检查网格是否为纯三角网格
└── 执行 isotropic_remeshing(边界保护模式)
Step 4: 输出修补结果
└── 将修补后的三角面写入目标网格
3.3 边界提取流程
输入: 三角网格
输出: 所有边界环(含外轮廓和内部孔洞)
Step 1: 数据结构转换
└── 将三角网格转换为半边数据结构
Step 2: 遍历所有半边,筛选边界半边
├── 遍历网格的所有半边
└── 收集所有对面不存在的半边(边界半边)
Step 3: 将边界半边连接成环
├── 为每条边界半边创建节点
├── 建立前后继关系(双向链表)
├── 检测共享边并建立关联
└── 从起点沿后继追踪直到回到起点
Step 4: 去重与格式化输出
├── 跳过共享边(只输出一次)
├── 跳过已遍历的边
└── 返回所有边界环列表
四、应用场景
4.1 三维实景模型的孔洞修补
城市三维重建中,建筑物的遮挡面、车辆/树木遮挡区域会出现孔洞:
- 利用
getBorderAndHole检测所有孔洞 - 对每个孔洞提取其邻域三角面
- 调用基于邻域的修补或基于折线的修补
- 可能需要反复迭代直到所有孔洞被修补
4.2 墙面修复
墙体表面因窗户、门洞等开口形成的孔洞,或者因遮挡形成的墙面缺失:
- 用户框选或绘制需要修复的墙面区域
- 对选定区域内的孔洞进行识别和修复
- 拉直弯曲的墙线,同时修补拉直后产生的缝隙
4.3 水表面修复
水面区域的孔洞、断裂或不连续处:
- 用户选取水面区域范围
- 对水面区域内的孔洞进行填充
- 与周边水面保持连续和光滑过渡
4.4 桥接(两个孔洞间)
在两个孔洞或多个网格碎片之间生成桥接网格:
- 用户选择两个孔洞的边界边
- 在两条边界边之间等分插值生成顶点
- 按四边形条带模式生成三角面
4.5 单体化建模
在建筑物单体化中,提取的屋顶、墙面等单体模型常常包含孔洞,需要修补后才能进行纹理映射和布尔运算。
五、算法复杂度
| 阶段 | 时间复杂度 | 说明 |
|---|---|---|
| 边界提取 | O(F)O(F)O(F) | 遍历所有半边,FFF为面数 |
| 环带构造 | O(k)O(k)O(k) | kkk为孔洞边界顶点数 |
| 三角化+细化+平滑 | O(k2)O(k^2)O(k2)~O(k3)O(k^3)O(k3) | 取决于孔洞大小 |
| 各向同性重网格 | O(mlogm)O(m\log m)O(mlogm) | mmm为修补后三角面数 |
空间复杂度:O(V+E+F)O(V+E+F)O(V+E+F)
六、算法优缺点
优点
- 双策略互补:基于邻域(保持局部特征)和基于折线(处理大孔洞)两种策略
- 高质量修补:包含平滑步骤,修补区域与周围网格过渡自然
- 三角面质量优化:重网格确保修补后无狭长三角面
- 边界保护:防止修补区域与原网格分离
- 功能全面:一函数同时提取外轮廓和内部孔洞
缺点
- 大型孔洞质量有限:跨结构的大孔洞可能产生不合理的几何形状
- 无纹理预测:修补区域无纹理,需额外步骤补全
- 非流形输入容错差:输入必须为流形网格
- 参数依赖:目标边长和迭代次数需根据模型尺寸调整
七、关键公式汇总
-
边界边判定:∣{f∈F:e⊂f}∣≤1|\{f \in F : e \subset f\}| \leq 1∣{f∈F:e⊂f}∣≤1
-
切向量计算(Gram-Schmidt):t=d2−(d2⋅d^1)d^1\mathbf{t} = \mathbf{d}_2 - (\mathbf{d}_2 \cdot \hat{\mathbf{d}}_1)\hat{\mathbf{d}}_1t=d2−(d2⋅d^1)d^1
-
目标边长:Ltarget=1n−1∑i=0n−2∥vi+1−vi∥2L_{target} = \sqrt{\frac{1}{n-1}\sum_{i=0}^{n-2}\|\mathbf{v}_{i+1} - \mathbf{v}_i\|^2}Ltarget=n−11∑i=0n−2∥vi+1−vi∥2
-
重网格长边分裂阈值:Lsplit=43LtargetL_{split} = \frac{4}{3} L_{target}Lsplit=34Ltarget
-
重网格短边塌缩阈值:Lcollapse=45LtargetL_{collapse} = \frac{4}{5} L_{target}Lcollapse=54Ltarget
-
法线偏差:Ndev=1−nTnew⋅nToriginalN_{dev} = 1 - \mathbf{n}_T^{new} \cdot \mathbf{n}_T^{original}Ndev=1−nTnew⋅nToriginal