高斯数量如何长出来:克隆、分裂、剪枝与预算

0 阅读6分钟

磁刻技术实录 · 第 16 篇 · 工程实践 · 技术基线 2026-10-31

我们正在做的「磁刻(Cike)」,是一款鸿蒙原生的生活记录与个性化创作应用。用户可以把照片做成数字冰箱贴,在洞洞板上自由摆放、组合自己的生活片段,也可以把喜欢的内容做成桌面卡片,用来回看、互动和分享。

在空间记录上,我们引入了 3D Gaussian Splatting(3DGS,三维高斯泼溅),把拍摄的视频重建为可以转动、靠近观察的三维场景。为此,我们重构了视频重建链路,实现自己的 C++ 训练核心,并结合鸿蒙原生的媒体处理、模型查看和分享能力,希望让用户保存一段经历时,也能留下当时的空间关系。

一段视频里,有的区域需要更多细节,有的区域却存在冗余或错误表示。我们希望磁刻把有限算力用在更有价值的地方,因此重建过程需要调整高斯数量。这篇分享克隆、分裂、剪枝与预算如何共同影响质量和稳定性。

固定数量的高斯很难同时照顾大面积平面和细小结构。初始稀疏点分布又不均匀,部分区域可能缺少足够表示。3DGS 因此在训练过程中调整高斯集合。磁刻的核心实现克隆、分裂和剪枝,并把点数预算作为致密化的一部分。

以下代码按当前实现整理,保留关键运算与控制顺序;省略导入、日志和完整类型,部分辅助函数采用说明性命名,片段需结合上下文阅读。

致密化的目标不是让模型越来越大,而是在有限资源内把表达能力放到更需要的位置。若只用最终高斯数量证明质量,很容易忽略计算增加和背景浮点。实验必须同时观察图像、覆盖、点数和耗时。

致密化的一次决策过程

图 1:先整理存活集合,再在预算内增加表示能力

误差统计应来自可见观察

当前实现累计高斯在可见帧中的屏幕位置梯度,并记录参与统计的次数。平均统计有助于避免一个点仅因出现次数更多就获得更高优先级。这里使用 mean2d,而不是混有投影和形状影响的完整三维位置梯度。

屏幕梯度大,意味着移动分布中心可以明显改变当前图像误差,但它仍是启发式信号。噪声、错误相机和运动主体也可能产生高梯度。因此,致密化不能代替上游几何检查,否则可能用越来越多高斯拟合不一致观察。

克隆与分裂服务不同尺度

较小高斯在局部需要更多表示时可以克隆;较大高斯则适合分裂为更小分布。分裂位置需要沿高斯自己的旋转轴安排,而不能始终沿世界坐标轴偏移。否则,倾斜椭球的子点会离开原本想表达的表面方向。

当前实现还关注克隆后的中心合成透明度。如果两个完全重叠的点都保留原透明度,合成结果会突然变得更不透明。结构变化应尽量保持局部外观连续,再让后续优化调整细节,而不是让每次致密化引入剧烈图像跳变。

预算顺序影响是否真正守住上限

先无限生成子点,最后截断数组,可能意外丢掉原有重要结构。当前流程为需要保留的存活高斯预留预算,再决定能够新增多少点。这使上限约束进入决策,而不是事后处理。

剪枝则移除低贡献或不符合条件的分布,同时整理梯度累计与 Adam 状态。所有数组必须维持相同身份对应关系。点数正确但状态错位,仍然会造成训练异常。

先保留存量,再分配增长预算

auto survivors = FilterFiniteAndUseful(gaussians);
auto candidates = SelectByMeanScreenGradient(survivors);
size_t available = max_gaussians > survivors.size()
    ? max_gaussians - survivors.size() : 0;
std::sort(candidates.begin(), candidates.end(), HigherScoreFirst);
if (candidates.size() > available) candidates.resize(available);
for (size_t id : survivors) {
    if (!Selected(id, candidates)) Keep(id);
    else if (MaxScale(id) <= split_scale_limit) Clone(id);
    else SplitIntoTwo(id);
}

每次二分或克隆净增加一个高斯,因此剩余预算可以直接限制候选数量。要先为所有存活高斯预留位置,包括本轮没有被看见、但仍然有效的点。若边遍历边增长,前面的候选可能抢完预算,使后面的正常点被意外丢弃。

候选分数来自屏幕位置梯度的观察均值,而不是简单使用三维位置梯度。大高斯需要更局部的表示时选择分裂,小高斯需要增加覆盖时选择克隆。完整实现还包含防止模型被全部剪空的保底逻辑;这里省略该分支以突出预算顺序。

三种数量变化的含义

图 2:增加点数只是手段,结构质量才是目标

训练日程需要留出恢复时间

新点加入后需要继续优化。如果在训练即将结束时大量分裂,子点可能没有时间适应颜色与透明度,最后保存的模型反而更差。当前 App 将致密化安排在训练中段,并在结束前保留一段继续拟合的时间。

App 当前默认从第 300 次附近开始,到 2,400 次范围内执行,间隔为 100,完整训练为 3,000 次;具体触发还受核心条件判断约束。核心支持透明度重置,但 App 默认关闭,不能把可配置机制写成每次生成必经步骤。

怎样比较致密化策略

固定素材、初始化和训练预算后,分别关闭致密化、只启用克隆、加入分裂,再改变数量上限。记录每个阶段点数曲线、训练视角与留出视角误差,并检查薄结构、边缘和背景浮点。平均分提高但局部破碎,仍需要进一步分析。

复制两个高斯,不能直接复制两份不透明度

float alpha = Sigmoid(parent.opacity_logit);
float child_alpha = alpha / (1.0f + std::sqrt(1.0f - alpha));
Gaussian first = parent;
Gaussian second = parent;
first.opacity_logit = Logit(child_alpha);
second.opacity_logit = Logit(child_alpha);
// 峰值处:1 - (1 - child_alpha)^2 == alpha

两个完全重合高斯的峰值合成 Alpha 是 1-(1-a)^2。如果每个孩子都保留原来的 Alpha,克隆瞬间就会让该位置更不透明,出现突兀的斑点。反解等效峰值后,单个孩子应使用较小的不透明度;代码中的分式是减少小 Alpha 相减误差的等价写法。

这个约束保持的是重合位置的峰值合成,不应扩大解释成整幅图像绝对不变。空间衰减和后续移动仍会改变其他像素的响应。分裂还会改变中心与尺度,更需要在固定视角记录操作前后图像,观察是否出现无法由正常优化恢复的跳变。

对于移动端,还应记录峰值内存和每轮耗时。点数增加不仅影响参数数组,也会增加 Tile 覆盖列表、排序和梯度归约的工作量。把质量收益与资源代价放在同一实验中,才能让致密化真正服务可用的端侧体验。