各位做三维视觉或者图形学方向的同学,最近可能都刷到过这样一类工作:点云生成不再是一次性把几千个点“画”出来,而是像细胞分裂一样,从一个粗粒度形状开始不断对半切开,每一层补上局部细节。这篇要聊的Learning to Tessellate: Point Cloud Generation via Recursive Spectral Partitioning,走的就是这条递归路线。它在标题里用了Tessellate这个词,多少有点向图形学里“曲面细分”致敬的意思——只不过传统细分切的是三角网格面片,这里切的是点与点之间的关系图。
先说我的判断:这种方法的真正价值,不在某个数据集的分数上,而在于它把点云生成从一个“端到端黑盒”变成了“可分解、可解释的结构化过程”。如果你正在研究点云生成,或者想在项目里落地一种比均匀采样更聪明的高密度点生成策略,这篇文章值得读完。我会先把方法里的三个关键词拆开讲清楚,再给出一套最小可运行的 Python 实现,最后补充评估方式和常见坑。
1. 为什么点云生成要换一种思路
点云生成的任务定义很简单:给定一个条件(类别标签、部分扫描、文本描述或隐向量),从里面采样出一组 3D 点坐标,让这组点尽量接近真实物体表面。但真正落地时,问题立刻变得不简单。
第一个难点是全球结构和局部细节的矛盾。一张椅子,整体要有四条腿、一个靠背和座面;局部来看,每条腿又要有光滑的表面、适当的粗细和朝向。如果让网络一次性输出全部 2048 个点,它会倾向于输出一个“平均形状”——整体轮廓对,但细节糊成一片。第二个难点是点的无序性。点云是一组无顺序的坐标集合,你不能像生成图片那样直接拿像素排列来监督网络。第三个难点是密度不均。同一个物体,椅背区域点可以少一些,扶手转角区域需要更多点,均匀地“撒”点永远覆盖不好细节。
递归方法给出的答案很简单:不要一次生成所有点,把问题拆小。先生成一个“大致骨架”,然后递归地决定哪些区域需要再次拆分,最后在每个最小区域里补充局部点。整个过程类似一个二分树,每一次决策都很小、很明确,网络或算法不需要在同一时刻把握全部复杂度。
这个视角其实不新,过去几年里基于八叉树的生成方法、层次化 VAE、两阶段生成器都做过类似尝试。但问题在于:八叉树按空间均匀网格切分,均匀切分不会尊重形状自身的结构。而这篇论文的关键,是用谱划分来决定“往哪切、怎么切”——结构由数据本身决定,而不是由空间网格决定。这也是它和传统递归生成最大的区别。
2. 核心概念:谱划分、递归划分、曲面细分
要理解这个方法,需要先建立三个基础概念。第一个是谱划分(Spectral Partitioning)。它的任务是把图切成两个子图,并且让切开两边之间的“联系”尽可能少。做法并不复杂:先把点看作图的节点,点与点之间只要距离近就建一条边,得到邻接矩阵;然后计算图的拉普拉斯矩阵;最后找出第二小特征值对应的特征向量,也就是常说的 Fiedler 向量。对这个向量里的每个数值,大于零的节点分到一组,小于零的节点分到另一组,就完成了一次二分。
第二个概念是递归(Recursive)。一次二分只把点集切成两半,而这通常不够。对切出来的每一半,再重复构建图、算谱、二分的流程,直到每个子集足够小或者达到预设深度。这样得到的是一个层次树结构,树根是全部点,叶子是不能再分的局部点块。整个点云生成过程,就变成了在这个树结构上不断向下生长。
第三个概念是曲面细分(Tessellate)。在传统图形学里,Tessellation 是把一个连续曲面离散成更小的三角形或多边形面片。这里借用了这个思想,但处理对象变成了离散点集。你可以把递归谱划分理解为一种“点云版本的曲面细分”——每次分割像是在形状表面划出一条边界,把大面片切成小面片,最终所有小面片拼起来就是完整形状。
为了更容易理解,这里把传统细分和论文中的思路做一个对比:
| 维度 | 传统曲面细分 | 递归谱划分生成 |
|---|---|---|
| 处理对象 | 连续曲面 / 三角网格 | 离散点集 |
| 划分方式 | 面片几何重划 | 基于图结构的谱分裂 |
| 决定性依据 | 网格拓扑或几何曲率 | 点间邻接关系与拉普拉斯特征向量 |
| 生成目标 | 逼近原曲面 | 逐层逼近目标点云分布 |
| 是否可学习 | 通常是固定规则 | 划分规则可被网络预测或学习 |
这个对比能解释标题里为什么用 Tessellate:作者希望读者意识到,点云生成本质上也是一种“把不可见形状不断细分到可表达粒度”的过程。
3. 从整体到局部:递归生成点云的整体框架
把上面三个概念串起来,就得到了方法的主干流程。
第一步是生成根节点。给定一个条件向量,例如类别嵌入或者残缺扫描的特征,用一个解码器输出一组数量较少的粗点,作为形状的初始表达。第二步是构建邻接关系。对当前点集中的每个点,找到距离最近的若干个邻居,建图。第三步是谱划分。计算拉普拉斯矩阵和 Fiedler 向量,按正负把当前点集一分为二。第四步是递归。对子集重复图构建和谱划分,直到子集大小或深度满足停止条件。第五步是叶子细节合成。在叶子节点上,用一个局部生成器补充足够数量的点,保证最终点云密度满足要求。最后把所有叶子节点的点合并,得到最终点云。
在这个框架里,树形结构本身就是生成结果的骨架。该在哪里多放点、哪里少放点,由划分边界的分布自动决定。细长结构会被持续切割,直到每个小块的局部方差足够小;大面积平面可能早早就停止分裂,节省了计算量。
这里需要强调一个容易误解的点:框架并不是只能和随机扰动或启发式方法配合。真正有价值的用法是把划分器和叶子生成器都换成可学习模块。例如用一个小型图神经网络来预测二分结果,用条件 MLP 来生成叶子点。谱划分在这里可以扮演两种角色:一种是作为训练数据生成器,为网络提供划分标签;另一种是作为前向过程中的硬约束,保证生成结果始终具有可递归分解的结构。
4. 环境准备与前置条件
本文后续的示例代码只依赖基础科学计算库,不需要安装深度学习框架。实验环境如下,版本以你本机实际环境为准,重点在于演示通用思路。
- 操作系统:Windows 10 / macOS / Linux 均可
- Python:3.9 及以上
- 核心库:NumPy、SciPy、Matplotlib
建议先创建一个干净的虚拟环境:
python -m venv tessellate-env source tessellate-env/bin/activate # Windows 下使用 tessellate-env\Scripts\activate然后安装依赖:
pip install numpy scipy matplotlib如果你后续想把这套流程接进 PyTorch 或 TensorFlow 工程,可以在安装上述依赖后,再按项目需要安装对应框架。但本文所有代码都不依赖深度学习框架,这也是刻意设计的——先把谱划分和递归生成这部分逻辑跑通,再谈大规模训练。
5. 核心算法实现:递归谱划分
这一节我们实现一个通用的递归谱划分函数。为了方便可视化,我使用二维点集来演示,但代码本身对任意维度生效。核心流程是:构建 KNN 图 -> 计算拉普拉斯矩阵 -> 求 Fiedler 向量 -> 按符号二分 -> 递归。
# 文件路径:spectral_partition.py import numpy as np from scipy.spatial import cKDTree from scipy.sparse import csr_matrix from scipy.sparse.linalg import eigsh def build_knn_graph(points, k=8): """构建 KNN 邻接图。 返回的是稀疏邻接矩阵,points[i] 与 points[j] 之间有边, 当且仅当其中一个点是另一个点的 k 近邻。 """ tree = cKDTree(points) # 这里取 k+1,因为 k 近邻会包含点自身 dist, idx = tree.query(points, k=min(k + 1, len(points))) # 构造 (i, j) 边索引 n = len(points) rows = [] cols = [] for i in range(n): for j in idx[i]: if i != j: rows.append(i) cols.append(j) data = np.ones(len(rows), dtype=np.float32) adj = csr_matrix((data, (rows, cols)), shape=(n, n)) # 对称化:保证邻接矩阵是对称的 adj = adj + adj.T adj.data[:] = 1.0 return adj def laplacian(adj): """计算标准拉普拉斯矩阵 L = D - A。""" deg = np.asarray(adj.sum(axis=1)).flatten() d_mat = csr_matrix((deg, (np.arange(len(deg)), np.arange(len(deg))))) return d_mat - adj def normalized_laplacian(adj): """计算对称归一化拉普拉斯矩阵 L_sym = D^{-1/2} L D^{-1/2}。""" deg = np.asarray(adj.sum(axis=1)).flatten() d_inv_sqrt = np.zeros_like(deg) mask = deg > 0 d_inv_sqrt[mask] = 1.0 / np.sqrt(deg[mask]) n = len(deg) d_mat = csr_matrix((d_inv_sqrt, (np.arange(n), np.arange(n)))) return d_mat @ (d_mat @ adj).T # 保守写法,等价于 D^{-1/2} (D - A) D^{-1/2}在求 Fiedler 向量时要注意一个细节:eigsh默认返回最大特征值对应的特征向量,我们需要的是最小的。使用which='SM'配合sigma参数可以让 ARPACK 收敛得更稳定。接下来写递归划分函数:
def fiedler_vector(adj, use_normalized=True): """返回图的 Fiedler 向量(第二小特征值对应特征向量)。""" if use_normalized: L = normalized_laplacian(adj) else: L = laplacian(adj) # 使用 shift-invert 模式求解最小特征值附近 # sigma 设置一个很小的数,能让 eigsh 更容易收敛到接近 0 的特征值 vals, vecs = eigsh(L, k=2, which='SM', sigma=-1e-6, maxiter=5000, tol=1e-4) # 特征值通常升序排列,第二列即第二个特征值对应的向量 return vecs[:, 1] def recursive_partition(points, max_depth=4, min_size=16, k=8, use_normalized=True): """递归二分点集,返回一个树结构。 树节点是 dict: - points: 当前子集的原始索引 - left: 左子节点 - right: 右子节点 - depth: 当前深度 """ n = len(points) indices = np.arange(n) def _build(idx, depth): node = { "indices": idx, "depth": depth, "left": None, "right": None, } # 停止条件:深度到达上限,或点数量小于阈值 if depth >= max_depth or len(idx) <= min_size: return node if len(idx) < 2: return node sub_points = points[idx] adj = build_knn_graph(sub_points, k=min(k, len(sub_points) - 1)) vec = fiedler_vector(adj, use_normalized=use_normalized) # 特征向量的符号本身不固定,切分前做符号对齐 if vec[0] < 0: vec = -vec left_mask = vec >= 0 right_mask = vec < 0 # 防止出现空子集 if left_mask.sum() == 0 or right_mask.sum() == 0: return node node["left"] = _build(idx[left_mask], depth + 1) node["right"] = _build(idx[right_mask], depth + 1) return node return _build(indices, 0)这段代码的关键点有三个:符号对齐、空子集防护、停止条件。特征向量的符号在数学上没有固定含义,同样的二分结果可能表现为向量取反,所以必须对齐;如果不加防护,递归可能出现某个子集为空,导致无限循环;停止条件决定了最终叶子的粒度,直接影响生成点云的密度和计算量。
6. 用递归划分生成点云的完整示例
有了划分树,点云生成就顺理成章:在每个叶子节点上,以叶子中心为基准,加入局部扰动,生成一点数量的点。为了模拟真实场景,我先构造一个类似“弯月”形状的参考点集作为输入,然后递归划分并生成新点。
# 文件路径:generate_demo.py import numpy as np import matplotlib.pyplot as plt from spectral_partition import recursive_partition def create_moon_points(n=600, noise=0.02): """生成一个月牙形点集,作为形状参考。""" t = np.linspace(0, np.pi, n) x = np.cos(t) y = np.sin(t) pts = np.stack([x, y], axis=1) # 在法线方向上加一些噪声,让点云更接近真实分布 pts[:, 0] += noise * np.random.randn(n) pts[:, 1] += noise * np.random.randn(n) return pts def generate_points_from_tree(tree, points, pts_per_leaf=16, scale=0.05): """在叶子节点上生成点,并合并所有叶子结果。""" if tree["left"] is None and tree["right"] is None: # 叶子节点:取中心,加高斯噪声 idx = tree["indices"] center = points[idx].mean(axis=0) local_std = points[idx].std(axis=0).mean() # 如果局部几乎无变化,使用一个保守的最小扰动 if local_std < 1e-6: local_std = scale generated = center + local_std * 0.3 * np.random.randn(pts_per_leaf, points.shape[1]) return generated left_pts = generate_points_from_tree(tree["left"], points, pts_per_leaf, scale) right_pts = generate_points_from_tree(tree["right"], points, pts_per_leaf, scale) return np.vstack([left_pts, right_pts]) if __name__ == "__main__": np.random.seed(42) # 1. 创建参考点集 ref = create_moon_points(600) # 2. 递归谱划分 tree = recursive_partition(ref, max_depth=4, min_size=16, k=8) # 3. 在每个叶子生成点 out = generate_points_from_tree(tree, ref, pts_per_leaf=16) # 4. 可视化:左图是参考点,右图是生成结果 fig, axes = plt.subplots(1, 2, figsize=(10, 4)) axes[0].scatter(ref[:, 0], ref[:, 1], s=1, c='blue', alpha=0.5) axes[0].set_title("Reference Points") axes[0].set_aspect('equal', adjustable='box') axes[1].scatter(out[:, 0], out[:, 1], s=1, c='red', alpha=0.5) axes[1].set_title("Generated Points via Recursive Spectral Partitioning") axes[1].set_aspect('equal', adjustable='box') plt.savefig("tessellate_demo.png", dpi=150) print("生成点数:", len(out)) print("参考点数:", len(ref)) plt.show()运行方式:
python generate_demo.py如果一切正常,你会看到两个形状相似的点云:参考点像一把弯月,生成点会保留弯月的整体形状,同时点与点之间的局部密度更均匀。注意,这里的“生成”并不是传统的模型推理,而是在谱划分给出的树结构上做了局部采样,所以质量上限等价于参考点的结构表达力。真正的论文方法会把叶子生成换成网络模块。
7. 效果验证:从直觉到量化
判断点云生成结果好不好,光看可视化是不够的,需要量化指标。最常用的是 Chamfer Distance(CD),它衡量两个点集之间的平均最近距离,值越低说明生成结果与参考越接近。另一个常用指标是覆盖率(Coverage),它计算参考点中有多少能通过生成点找到最近邻居,值越高表示覆盖越完整。
下面是 CHD 的一个最小实现,使用 SciPy 的 KDTree 来加速查询:
# 文件路径:evaluate.py import numpy as np from scipy.spatial import cKDTree def chamfer_distance(pred, gt): """计算 pred 与 gt 之间的双向 Chamfer Distance。""" tree_pred = cKDTree(pred) tree_gt = cKDTree(gt) # 每个 pred 点到最近 gt 点的距离平方 dist_pred_to_gt, _ = tree_gt.query(pred, k=1) # 每个 gt 点到最近 pred 点的距离平方 dist_gt_to_pred, _ = tree_pred.query(gt, k=1) cd = dist_pred_to_gt.mean() + dist_gt_to_pred.mean() return cd def coverage(pred, gt, threshold=0.05): """参考点中被生成点覆盖的比例。threshold 可理解为覆盖半径容忍度。""" tree_pred = cKDTree(pred) dist, _ = tree_pred.query(gt, k=1) return (dist < threshold).mean()在本文的演示里,因为叶子生成器用的是高斯噪声,随着pts_per_leaf增大,CD 会先下降后趋于稳定;覆盖率会稳步上升。实践中,如果发现 CD 高但覆盖率也高,可能说明生成点分布过散;如果 CD 低但可视化出现空腔,可能说明叶子停止条件设得太早。
验证时建议关注三个现象:
- 生成点云的形状拓扑和输入大体一致,没有明显断裂或融合。
- 点密度没有集中在少数几个叶子节点里,说明递归划分比较均衡。
- 可视化结果在新随机种子下稳定,说明划分过程对符号对齐和停止条件设置是稳健的。
8. 常见问题与排查思路
递归谱划分在工程实现中会踩不少坑。下表总结了我在复现类似流程时遇到的典型问题。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
eigsh抛 ARPACK 不收敛 | 拉普拉斯矩阵是大规模稀疏矩阵,特征值近似严重 | 查看 ARPACK 报错信息,尝试增大迭代次数 | 使用tol=1e-3或增大maxiter;也可以改用scipy.sparse.linalg.lobpcg |
| Fiedler 向量符号每次运行不一致 | 特征向量只有方向有意义,符号本身随机 | 打印两次运行的向量并对比 | 在二分前用vec[0]或平均方向做符号对齐 |
| 递归划分严重不平衡 | KNN 图的k太大或太小,导致图结构失真 | 打印左右子集数量 | 调小k,或改用归一化拉普拉斯矩阵 |
| 生成点云出现大面积空白区域 | 叶子停止条件太严或叶子生成点太少 | 可视化叶子边界 | 增大min_size,或增加叶子驻留点数 |
| 内存占用过高 | 构建全连接图或直接求稠密特征向量 | 监控内存使用 | 使用 KNN 稀疏图;对大规模点云避免多次完整特征分解 |
| 低层叶子重叠严重 | 叶子扰动方差过大 | 计算生成点与参考点的最近距离 | 降低局部扰动幅度,或让叶子生成器学习局部残差 |
每次改动超参数后,建议先在小数据集上跑一轮,观察划分树的深度和叶子数量,再放大到完整点云。这样能节省大量调试时间。
9. 最佳实践与工程建议
递归谱划分从论文到工程落地,中间还有一段距离。下面这些建议可以帮你少走弯路。
第一,邻接图的k值需要随点云规模动态调整。经验上,中等规模点云(1000 到 10000 点)取 8 到 16 比较合适;点数很少时,k不能超过总点数减一,否则 KDTree 查询会返回无效近邻。更稳妥的做法是使用包围盒密度来估计每点邻居数。
第二,优先使用对称归一化拉普拉斯矩阵。标准拉普拉斯在点密度不均时,会把划分结果偏向高密度区域;对称归一化拉普拉斯能减弱点密度带来的偏差,让划分更贴合形状结构。
第三,停止条件建议用“最小点数”而不是“最大深度”为主。深度固定可能导致深处叶子仍然点很多,或浅层叶子已经碎片化;用最小点数做停止条件,能让每个叶子承载足够信息,生成层也更稳定。
第四,大规模点云不要反复做稠密特征分解。每次二分都做特征分解的复杂度不可忽略,可以结合 LOBPCG 等迭代方法,或者先对点云做一次粗采样,在小规模子集上预计算划分方向。
第五,如果要和深度学习结合,要注意谱划分本身不可导。训练时需要把划分器训练成“预测划分”,而不是“执行划分”,或者使用软化版本让梯度能够穿透划分边界。这是这类方法从演示走向实用最需要突破的工程点。
第六,生产环境建议把点云生成任务封装成完整流水线,包括点云读入、下采样、归一化、谱划分、生成、后处理六个环节,每个环节记录日志。随机种子要固定,否则每次运行结果不同,会影响实验可复现性。
10. 总结与后续学习方向
这篇文章围绕Learning to Tessellate这条技术路线,把点云生成问题拆成了三个层次:底层是图的谱划分原理,中间是递归树的结构表达,顶层是生成器的局部填充。核心不复杂:用拉普拉斯矩阵的特征向量决定“往哪里切”,用递归树组织多尺度生成,用局部生成器补充细节。真正值得继续研究的方向有两个。一是把谱划分本身学习化,让网络在预测划分边界的同时最大化生成质量;二是把递归树的每个节点当作条件生成入口,接入 Diffusion 或 VAE 做局部生成,从而兼顾全局结构和局部细节。建议你先把今天的示例跑通,再去读论文原文里的实验部分,对照模型框架理解设计选择。点云生成远没有到终点,可解释、可递归、可分解的路径,很可能是下一个突破口。