HAC++ 论文与代码笔记
论文: HAC++: Towards 100X Compression of 3D Gaussian Splatting
代码仓库 : YihangChen-ee/HAC-plus
Intro
However, compressing 3D Gaussians is inherently challenging due to their sparse and unorganized nature.Various methods have been proposed to address this issue by utilizing Gaussian pruning or codebook-based vector quantization…However, they share a common limitation: they focus on parameter “values” while neglecting the redundancies inherent in structural relations. 然而,由于3D高斯体的稀疏和无序特性,压缩它们本身就具有挑战性,已有多种方法通过高斯剪枝或基于码本的向量量化来解决这一问题,如图1所示。然而,它们有一个共同的局限性:它们只关注参数”值”,而忽略了结构关系中固有的冗余。
To address this, Scaffold-GS introduces anchors to cluster related 3D Gaussians and uses neural predictions to infer their attributes from attributes of the anchor, achieving substantial storage savings.Despite advancements, Scaffold-GS treats each anchor independently, leaving anchors sparse, unorganized, and difficult to compress due to their point-cloud-like nature.
为解决此问题,Scaffold-GS引入了锚点来聚类相关的3D高斯体,并使用神经预测从锚点属性推断其属性,从而实现了可观的存储节省。尽管取得了进展,Scaffold-GS将每个锚点独立处理,使得锚点仍然是稀疏、无序的,并且由于其点云般的性质而难以压缩。
作者在文章中提到了,Scaffold-GS中无序锚点的属性与结构化特征网格之间存在着固有关系,因此提出了HAC++方法,核心思想是一个哈希网格辅助上下文(HAC)框架,用于联合学习一个结构化的紧凑哈希网格(具有二值化的哈希参数),并将其用于锚点属性的上下文建模
此外此外,引入了自适应量化模块(AQM),该模块动态调整不同锚点属性的量化步长,在保留原始信息与降低熵之间取得平衡。采用可学习的掩码来排除无效的高斯体和锚点,进一步提高了压缩比。

Related Work
1. NeRF及其压缩
- NeRF隐式MLP沿光线采样 → 渲染慢
- 改进:引入显式网格表示(Instant-NGP, TensoRF, K-planes, DVGO)→ 加速训练/渲染,但存储增大
- 压缩分两类:
- 基于值:剪枝、码本、量化、熵约束。缺点:忽略参数间的相互关联
- 基于结构关系:小波分解、秩残差分解、空间预测。利用网格空间冗余提升压缩性能
2. 3DGS及其压缩
- 3DGS以显式高斯体 + 可微泼溅实现快速高质量渲染
- 但高斯体稀疏无序,难以建立结构关系,早期方法多沿用NeRF的基于值压缩思路
- 基于结构关系的新方向:
- Scaffold-GS:以锚点为中心,用特征神经预测高斯属性,在提升保真度的同时减少参数
- HAC(本工作前身,ECCV’24):用紧凑二值化哈希网格挖掘锚点间空间冗余
- 其他:SUNDAE(谱图建模)、Mini-Splatting(空间关系感知)、IGS(多层网格预测)、ContextGS / CompGS(层级锚点建模)
3. 现有不足与本文改进
- 现有方法关注锚点间关系,但忽略了锚点内部的冗余
- 本文以Scaffold-GS为基础,同时利用锚点间上下文(inter-anchor) + 锚点内上下文(intra-anchor),结合改进的剪枝策略,实现更紧凑的3DGS表示
Method Of HAC++
A 预备知识
3DGS 高斯定义:
- (缩放矩阵 + 旋转矩阵 保证半正定)
- 每个高斯体由协方差 和位置 定义
渲染公式(α-blending):
- α:投影后的不透明度,c:SH系数建模的视角相关颜色
Scaffold-GS 锚点结构:
- 每个锚点:位置 + 属性
- 渲染时 MLP 从锚点属性推导出高斯体属性,位置 , 约束高斯体位置和形状
- 相比直接存储高斯体,锚点方式更省存储、保真度更高
B 桥接锚点与哈希网格
主要思想是利用结构良好的哈希网格来揭示无序锚点中固有的空间一致性(即附近锚点的属性值相似)
实验9(锚点在3D空间中具有内在一致性)

各个锚点的比特分配可视化。3D 空间经过体素化处理,每个体素由一个球体表示,球体的半径指示该体素内锚点的数量。
结论
- 模型倾向于在外观复杂或尖锐边缘区域分配更多的总比特数,例如,“lego”中的边缘区域和”materials”中的镜面物体,由于纹理复杂,表现出更高的总比特消耗
- 在高比特消耗的体素中,通过创建更多锚点来进行精细建模,实际上平均了每个锚点的比特消耗,从而平滑或降低了每个锚点的比特数
- 这与我们的假设一致:锚点在3D空间中具有内在一致性——附近锚点的属性值相似,使得哈希网格能够更准确地估计它们的概率。
预实验(证明哈希网格与锚点之间的互信息)
为验证哈希网格与锚点之间的互信息,首先尝试用哈希特征 替换锚点特征 ,其中 通过锚点位置 在哈希网格 上插值获得,定义为
这里 表示哈希网格, 是向量 的维度, 是层级 的表大小, 是层级数。
哈希网格结构详解
哈希网格 = 多级规则网格,每级挂一张固定大小的哈希表。网格的顶点存可学习的特征向量,锚点通过插值获得自己的特征。
训练配置:(代码 train.py:115,通过 --n_features=4 覆盖类默认值 2)
| 参数 | 值 |
|---|---|
| 3D 层级数 | 12 (分辨率 18³ |
| 每级哈希表大小 | 项 |
| 每项维度 | 4(代码默认参数 --n_features=4) |
| 额外 2D 投影 | xy, xz, yz 各 4 级 (分辨率 130³~1026³) |
| 总特征维度 |
哈希表存的不是锚点的特征,是网格顶点的特征。 锚点的 是通过网格顶点插值算出来的。
哈希函数详解
为什么不直接用顶点坐标当索引?如果用 直接当索引,需要 分辨率³ 的存储,Level 12 下 个格子 × 4 bytes = GB 级。所以用哈希压缩到固定大小 项。
哈希函数 = 坐标分别乘大质数 → XOR 混合 → 取模:
hash(x, y, z) = (x × 73856093) XOR (y × 19349663) XOR (z × 83492791) % 8192
# 例: Level 3 中顶点 (23, 18, 27):
# 23 × 73856093 = 1,698,690,139
# 18 × 19349663 = 348,293,934
# 27 × 83492791 = 2,254,305,357
# XOR 再 % 8192 = 37 → 查哈希表第 37 项
3 个大质数的作用:使 XOR 结果均匀分布,减少不同 (x,y,z) 映射到同一索引的概率(碰撞)。
顶点查询 → 插值全过程
一个 3D 网格格子有 8 个顶点。给定锚点位置,找到所在格子的 8 个顶点,查表,再做三线性插值。
例: Level 3 (分辨率 46³) 查询锚点坐标 (0.500, 0.400, 0.600)
Step 1: 归一化到 [0,1]
x = (世界坐标 - x_min) / (x_max - x_min)
Step 2: 找格子
格子大小 = 1/46 ≈ 0.0217
gx = floor(0.500/0.0217) = 23
gy = floor(0.400/0.0217) = 18
gz = floor(0.600/0.0217) = 27
小数部分(插值权重):
fx = 0.500/0.0217 - 23 = 0.042
fy = 0.400/0.0217 - 18 = 0.054
fz = 0.600/0.0217 - 27 = 0.051
Step 3: 8 个顶点坐标
000: (23,18,27) 100: (24,18,27)
010: (23,19,27) 110: (24,19,27)
001: (23,18,28) 101: (24,18,28)
011: (23,19,28) 111: (24,19,28)
Step 4: 每个顶点 hash 查表
hash(23,18,27) % 8192 = 37 → θ[37] = [训练后的值]
hash(24,18,27) % 8192 = 612 → θ[612] = [训练后的值]
hash(23,19,27) % 8192 = 38 → θ[38] = [训练后的值]
...(其余 5 个顶点同理,一般映射到不同索引)
Step 5: 三线性插值(7 次一维线性插值)
8 个顶点 → 3 轮插值 → 1 个最终值 (2³ - 1 = 7 次 lerp)
沿 x 轴: 4 条边, 每条 2 端点插值 → 4 个中间点 (4 次)
例: c0_x = (1-0.042)×θ[37] + 0.042×θ[612]
沿 y 轴: 2 条边, 各 2 中间点插值 → 2 个中间点 (2 次)
沿 z 轴: 1 条边, 2 中间点插值 → 最终 f^h_level3 (1 次)
三线性插值的意义:锚点位置不在网格顶点上。无插值(最近邻)会导致格子内不同位置无区分、梯度只在顶点处更新。插值保证连续空间 → 连续 ,梯度平滑传播。
为什么要用哈希表代替密集网格
| 方案 | 存储量 (Level 12) | 问题 |
|---|---|---|
| 密集网格: arr[512][512][512] | 512³ × 4 × 4B ≈ 2 GB | 存了大量空空间 |
| 哈希表: hash(x,y,z) % 8192 | 8192 × 4 × 4B ≈ 128 KB | 冲突但可接受 |
哈希冲突是必然的但不是致命的:
多个不同顶点映射到同一表项 → 共享同一个 4 维向量。不同位置的梯度叠加,该向量学到折中值。但多级互补:该 Level 冲突的维度被 MLPc 自动忽略(给接近 0 的权重),其他不冲突的 Level 提供足够信息。
实际效果(论文 Table III):去掉哈希网格(设为全零)→ BD-rate +63.3%,说明即使有冲突,哈希网格提供的空间上下文仍然大幅提升压缩率。
多级分辨率的配合
| Level | 分辨率 | 格距 | 捕捉的信息 | 锚点 A / B 是否在同一格子 |
|---|---|---|---|---|
| 1 | 16³ | ~6% 场景宽 | ”这是一面墙” | 是 (粗粒度) |
| 3 | 46³ | ~2% 场景宽 | ”墙面中部” | 是 |
| 6 | 128³ | ~0.8% | “墙的左半” | 有时相同 |
| 12 | 512³ | ~0.2% | “墙左下角, 靠近踢脚线” | 不同 (细粒度区分) |
同时包含大范围趋势和小范围细节 → 96 维的多尺度空间编码。

Nonetheless, we find the fidelity degradation remains moderate, suggesting the existence of rich mutual information between and .
尽管如此,保真度下降幅度有限,说明 与 之间存在丰富的互信息。
这启发我们思考:能否利用这种相互关系,用紧凑的哈希特征来建模锚点属性 的上下文?由此引出条件概率形式的上下文建模:
最后一项中省略 ,因为我们假设 与 独立(可在任意位置),即 ,且不对 施加熵约束,根据信息论,更高的概率对应更低的不确定性(熵)和更少的比特消耗。因此, 与 之间的大量互信息保证了 较大
# gaussian_model.py
def forward(self, x):
"""
输入: x [N, 3], 锚点位置 (世界坐标)
流程:
1. 归一化坐标到 [0, 1]
2. 多分辨率哈希网格插值
输出: f^h [N, 96], 哈希特征
"""
assert len(x.shape) == 2 and x.shape[1] == 3
assert torch.abs(self.x_bound_min - torch.zeros(size=[1, 3], device='cuda')).mean() > 0
x = (x - self.x_bound_min) / (self.x_bound_max - self.x_bound_min) # to [0, 1]
features = self.encoding_xyz(x) # [N, 96]
return features
C HAC 框架
HAC 框架的目标:利用哈希特征 消除锚点间的冗余,最小化锚点属性 的熵(即最大化 ),从而减少熵编码所需比特。
整体流程:
x_a → calc_interp_feat → f^h [N, 96]
↓
mlp_grid (Linear 96→100, ReLU, Linear 100→225) ← 同个MLP
↓ split
┌────────────────────────────────────┐
│ μ^s[50], σ^s[50], π^s[50] MLPc │ 高斯分布建模
│ μ_l[6], σ_l[6] MLPc │
│ μ_o[30], σ_o[30] MLPc │
├────────────────────────────────────┤
│ r_f[1], r_l[1], r_o[1] MLPq │ AQM步长调整
└────────────────────────────────────┘
论文指出:MLPq 和 MLPc 合并为一个 3 层 MLP(即 mlp_grid),共享浅层参数。
C.1 自适应量化模块(AQM)
为什么需要量化
熵编码器只能处理离散符号,锚点属性 (连续 float)必须先量化为有限集合。
二值化不可行: 直接将 二值化为 信息损失过大。实验表明,简单二值化所有 后在 Synthetic-NeRF 数据集上 PSNR 仅 31.27 dB(完整模型 33.80 dB),质量不可接受。因此选择保留完整特征的取整量化。
为什么需要自适应步长
最简单的取整本质是步长为 1 的量化,但对不同属性不适用:
| 属性 | 典型值范围 | 步长=1 的问题 |
|---|---|---|
| (锚点特征) | ~[-10, 10] | 3.22 → 3,误差 0.22,可接受 |
| (scaling) | ~[0, 0.05] | 0.032 → 0,信息丢失 |
| (offsets) | ~[-3, 3] | 0.65 → 1,误差 0.35,太大 |
所以设三种基础步长 ,再由 MLPq 为每个锚点自动微调:
Q₀_feat = 1 # f^a: 整数量化足够
Q₀_scaling = 0.001 # l: 值很小,必须精细
Q₀_offsets = 0.2 # o: 中等精度
核心公式
公式分两部分理解:
第一部分: — MLPq 从哈希特征 预测一个调整量 (标量,范围 )。 携带空间信息,平坦区域 → 偏正,复杂区域 → 偏负。
第二部分: — 用 Tanh 把 从无限范围压缩到 ,再映射到 :
为什么用 Tanh? 如果没有 Tanh, 可能跑到 ±1000,导致步长失控甚至为负。Tanh 保证 ,永远正且不会太大—
“restricts qi to be chosen within (0, 2Q₀)”。
三种属性各自独立:
| 属性 | Q₀ | 理论范围 | 含义 |
|---|---|---|---|
| f^a | 1 | (0, 2) | 整数量化级 |
| l | 0.001 | (0, 0.002) | 值很小,必须精细 |
| o | 0.2 | (0, 0.4) | 中等精度 |
训练时加噪量化:
测试时取整量化:
为什么训练和测试不同? 梯度几乎处处为 0,无法反向传播。训练时用加均匀噪声替代——数学期望上等价于量化,但梯度可正常通过()。测试时才真正取整,得到离散编码符号。
代码实现
训练时(gaussian_renderer/__init__.py:59-69):
feat_context = pc.get_grid_mlp(pc.calc_interp_feat(anchor))
mean, ..., Q_feat_adj, Q_scaling_adj, Q_offsets_adj = torch.split(feat_context, ...)
Q_feat = Q_feat * (1 + torch.tanh(Q_feat_adj)) # q = Q₀ × (1+Tanh(r))
feat = feat + torch.empty_like(feat).uniform_(-0.5, 0.5) * Q_feat # 加噪声
测试时(encodings.py:56-70 STE_multistep):
# 前向
Q_round = torch.round(input / Q)
Q_q = Q_round * Q # 真正取整量化
# 反向(直通梯度,假装没量化)
def backward(ctx, grad_output):
return grad_output, None
数值示例
# 锚点 A(平坦墙面):
# f^h = [0.95, -0.88, ...墙面特征...]
# MLPq 输出 r_f = -0.12
q_f = 1 × (1 + tanh(-0.12)) = 1 × 0.88 = 0.88
# 训练时:
f̂ = 3.22 + Uniform(-0.44, 0.44) = 3.22 + 0.12 = 3.34 ← 噪声模拟
# 测试时:
round(3.22 / 0.88) = round(3.66) = 4 ← 离散符号
f̂ = 4 × 0.88 = 3.52 ← 重建值
消融实验
论文 Table III:去掉 AQM(全用固定 )→ BD-rate 不可算(N/A),因为 固定 导致复杂区域质量暴跌,保真度与完整 HAC++ 无重叠区间。说明 AQM 不可或缺。
C.2 HAC 的高斯分布建模
前置概念
是锚点的”特征向量”,Scaffold-GS 用来生成 3D 高斯体的属性。
每个锚点有 3 种属性,记作集合 :
其中:
- :锚点特征,用于 MLP 生成高斯体属性。
- :scaling,控制高斯体大小。
- :offsets,10 个高斯体的位置偏移 (10×3)。
在代码中叫 pc._anchor_feat:
# __init__.py:34
feat = pc._anchor_feat[visible_mask] # f^a: [N, 50]
50 维的向量,每个维度没有具体语义,是整个模型训练出来的隐式表征。
量化后记作 , 只有量化之后才能送进熵编码器
论文在 AQM 节定义:
“we denote as any of its ‘s components: ”
是占位符,统一指代第 个锚点的任意一个属性。 三个属性共用同一套量化公式:
动机: 要量化 的比特消耗,需估计 。统计发现 各分量均近似高斯分布。
为什么需要比特数?
3D 场景要存成文件,每个锚点属性是 50 个数字,例如 。
最简单的存法:每个 float 用 32 位 → 比特/锚点。5 万个锚点 80 Mbit 10 MB。
HAC++ 的目标:每个 float 用平均不到 1 位 → 比特/锚点。5 万个锚点 2 Mbit 0.25 MB。
为什么需要概率,和比特有什么关系?
核心思想:一个事件的信息量 = 它的意外程度。
香农信息论的核心公式,也是 HAC++ 的训练目标:
| 情况 | p | bit | 含义 |
|---|---|---|---|
| MLPc 猜得很准 | 很少比特 | ||
| 抛硬币 | 适中 | ||
| 完全猜错 | 花很多比特 |
越大 bit 越少。 压缩就是想方设法让 变大。
概率和特征有什么关系?
不看空间位置时,所有锚点混在一起统计——全局概率 :
p(3.52) = 200次/50000个锚点 = 0.004
bit = -log₂(0.004) ≈ 8 比特
用 知道”这个锚点在哪”后——条件概率 :
f^h 告诉 MLPc: "这个锚点在一面平坦墙上"
MLPc 预测: µ=3.21, σ=0.12 → p(3.52 | 这面墙) = 0.86
bit = -log₂(0.86) ≈ 0.22 比特 ← 比全局省 36 倍
提供了空间上下文,让 MLPc 能预测更准确的 ,而不是粗糙的全局 。
注意:p 永远不会等于 1.0。p 大时比特少是因为算术编码区间变窄,但总需要一些 bit 来精确定位。bit 的真正含义不是”存 f̂ 这个数”,而是”缩小概率区间所需的额外信息量”。
提供了空间上下文,让 MLPc 能预测更准确的 ,而不是粗糙的全局 。
怎么计算
条件概率的含义:
无条件概率 不看位置——把所有锚点混在一起统计:
p(3.52) = 200次/50000个锚点 = 0.004
bit = -log₂(0.004) ≈ 8 bit ← 模糊,比特多
条件概率 看了 (知道锚点在哪面墙)后再统计:
p(3.52 | f^h=墙面) = MLPc 预测 µ=3.21, σ=0.12 → p ≈ 0.86
bit = -log₂(0.86) ≈ 0.22 bit ← 精准,比特少
条件概率 = 用额外信息()把模糊的全局统计变成精准的局部统计。
为什么不直接数频率?
如果按位置分组,每组锚点太少(某一小块墙面可能只有几十个锚点),直接数不稳定。所以用”建模法”——MLPc 从 f^h 预测 (µ, σ),用高斯曲线拟合分布,然后用曲线下的面积来估计概率。
为什么可以用曲线面积算概率?
概率论的基本定理:概率密度曲线下某一区间的面积 = 该区间内取值的概率。
概率密度 φ(x)
↑
| ╭──╮
| ╭╯ ╰╮
| ╭╯██████╰╮ ← 区间 [f̂-q/2, f̂+q/2] 阴影面积 = p(f̂)
├─────╯██████████╰──→ x
µ f̂
就是”从下界到上界这一小段曲线下的面积”,这个面积就是量化值 f̂ 出现的概率估计值。
论文 Fig.3 已经证明 的分布都像钟形(高斯),所以用高斯拟合是合理的。

代入公式 (6) ,每个属性各自独立计算:
对 ,MLP用估计 :
对 :
对 :
同一批 (哈希特征)进入 MLPc,出来三组不同的 (),分别对应 。
整条链路
锚点位置 xₐ
↓
哈希网格 → f^h(这个锚点在哪?)
↓
MLPc → (µ, σ)(预测该锚点的高斯分布)
↓
┌──────────────────────────────┐
p(f̂) = Φ(上界|µ,σ) - Φ(下界|µ,σ) ← 公式(6) │ 算 p
└──────────────────────────────┘
↓
bit = -log₂(p) ← 香农公式 算比特
↓
总比特 → 反向传播 → MLPc 猜更准
特征()提供空间上下文 → 概率()反映预测确定程度 → 比特数()衡量压缩成本。MLPc 的任务就是用 猜准 ,让总比特最小。
D 锚点内上下文与 GMM(Gaussian Mixture Model)
D.1 锚点内上下文模型
动机: HAC 消除了锚点间的空间冗余,但 内部 50 个通道之间仍有相关性——相邻通道值往往相近。HAC 对所有 50 维用同一套 (µ, σ),没有利用这种通道间关系。
核心思想:自回归预测分布 做法: 将 (50 维)分为 个块,每块 维,逐块自回归预测。
f̂^a = [ d0 | d1 | d2 | d3 | d4 ]
f̂^a[0:10] f̂^a[10:20] f̂^a[20:30] f̂^a[30:40] f̂^a[40:50]
↑ ↑依赖d0 ↑依赖d0,d1 ↑依赖d0~d2 ↑依赖d0~d3
公式:
| 符号 | 含义 | 值 |
|---|---|---|
| chunk 数量 | 5 | |
| 每 chunk 维度 | 10 | |
| 前面已解码的 chunk 值 | 已存在的真实值 | |
| HAC 预测的参数(额外条件) | 来自 MLPc |
代码实现(gaussian_model.py:116 Channel_CTX_fea):
每个 chunk 有独立的 MLP:
# 5 个 MLP,输入维度逐渐增大:
MLP_d0: input=150 (仅HAC输出 concat(µ^s, σ^s, π^s)) → output=30 (µ+σ+π各10)
MLP_d1: input=160 (d0真实值 + HAC) → output=30
MLP_d2: input=170 (d0,d1真实值 + HAC) → output=30
MLP_d3: input=180 (d0~d2真实值 + HAC) → output=30
MLP_d4: input=190 (d0~d3真实值 + HAC) → output=30
前向计算(forward):
d0, d1, d2, d3, d4 = split(f̂^a, [10,10,10,10,10])
# d0: 只用 HAC 参数
mean_d0, scale_d0, prob_d0 = split(MLP_d0(HAC输出), 3)
# d1: HAC + d0 的真实值
mean_d1, scale_d1, prob_d1 = split(MLP_d1(concat(d0, HAC输出)), 3)
# d2: HAC + d0,d1 的真实值
mean_d2, scale_d2, prob_d2 = split(MLP_d2(concat(d0,d1, HAC输出)), 3)
# 拼接所有 chunk → µ^c, σ^c, π^c 各 [50]
mean_adj = concat(mean_d0, mean_d1, ..., mean_d4)
HAC vs Intra-Anchor 对比:
HAC 上下文(s): 从 f^h 预测 → 空间冗余 → σ=0.12
Intra-Anchor(c): 从已解码值预测 → 通道间冗余 → σ=0.08
为什么只对 f^a 不做 l 和 o:
论文原文:
“this approach is applied only to the anchor feature f^a, as the internal redundancies of other attributes are negligible”
| 属性 | 维度 | 为什么不适用 |
|---|---|---|
| 6 | 维度太小,省也省不了几个比特,不值得加额外 MLP | |
| 30 | 已被自适应掩码(Sec E)剪掉了冗余部分 |
消融实验(Table III):
W/o intra-anchor prob → BD-rate +14.7%
去掉 Intra-Anchor 后大小增加 14.7%。比 HAC 的 +63.3% 小,但仍然是可观的提升。
D.2 GMM — 融合两个上下文
动机: HAC()提供了空间上下文,Intra-Anchor()提供了通道间上下文。各有优势,两者融合比单用任何一方更准。
其实就是加权平均,使用了SoftMax
GMM 公式:
| 来源 | 含义 | |
|---|---|---|
| HAC(从 ) | 空间上下文:这个锚点在哪面墙上 | |
| Intra-Anchor(从已解码 chunk) | 通道上下文:前面几个通道是多少 |
具体数值计算:
# 锚点 A,第 0 维:
# HAC 预测:
µ^s = 3.21, σ^s = 0.12, π^s = 0.33
# Intra-Anchor 预测(已看过 d0=3.52):
µ^c = 3.22, σ^c = 0.08, π^c = 0.67
# Step 1: softmax 算权重
probs = softmax([π^s, π^c])
= [exp(0.33)/(exp(0.33)+exp(0.67)), exp(0.67)/(...)]
= [0.416, 0.584]
# HAC 占 41.6%, Intra-Anchor 占 58.4%
# π 是学习出来的,模型自动学会谁的预测更可靠
# Step 2: GMM 融合概率
p_HAC = Φ(3.52+0.44|3.21,0.12) - Φ(3.52-0.44|3.21,0.12) = 0.86
p_Intra = Φ(3.52+0.44|3.22,0.08) - Φ(3.52-0.44|3.22,0.08) = 0.92
p_GMM = 0.416 × 0.86 + 0.584 × 0.92 = 0.895
bit_GMM = -log₂(0.895) = 0.16 比特
bit_HAC_only = -log₂(0.86) = 0.22 比特
bit_Intra_only = -log₂(0.92) = 0.12 比特
# GMM 在两者之间取加权 → 比单用 HAC 好
为什么 GMM 比简单拼接更好:
论文做了消融实验:
W/o using GMM (改用简单拼接) → BD-rate +5.7%
简单拼接 = 把两路参数合并成一路,只输出一套 (µ, σ)。GMM 保留两套分布各自独立,用权重动态融合——当 HAC 更可靠时给 HAC 大权重,Intra-Anchor 更可靠时给 Intra 大权重。
代码实现(__init__.py:104-108):
完整数据流:
对 f^a(锚点特征):
f^h ─→ MLPc ─→ µ^s, σ^s, π^s ─┐ ┌─ HAC 分支
│ │
f̂^a → MLPa ─→ µ^c, σ^c, π^c ─┼── GMM ─→ p ─→ bit = -log₂(p)
│ softmax │
π^s, π^c ─→┘ (权重) └─ Intra 分支
对 l 和 o:
只用 HAC 单高斯(没有 GMM),因为 l 维度太小不值得,o 已被掩码处理。
E 自适应偏移掩码
动机: 在零点处有脉冲 → 大量 offset ≈ 0,这些高斯体是冗余的应剪掉。若锚点的全部 K 个偏移都被剪,整个锚点也应删除。
两类掩码
1. 高斯级掩码 (公式 9)— 每个偏移是否有效
代码实现(gaussian_model.py:464-468):
def get_mask(self):
mask_sig = torch.sigmoid(self._mask) # [N, 10, 1],可学习参数
mask = ((mask_sig > 0.01).float() - mask_sig).detach() + mask_sig
return mask # [N, 10, 1],前向 0/1,梯度直通
2. 锚点级掩码 (公式 10)— 锚点是否还有有效高斯体
代码实现(gaussian_model.py:471-474):
def get_mask_anchor(self):
mask = self.get_mask # [N, 10, 1]
mask_rate = torch.mean(mask, dim=1) # [N, 1]
mask_anchor = ((mask_rate > 0.0).float() - mask_rate).detach() + mask_rate
return mask_anchor # [N, 1]
如果 (全部偏移被剪)→ → 整个锚点在 density control 时删除。
| 掩码 | 粒度 | 含义 |
|---|---|---|
| 高斯球 | 有效, 无效 | |
| 锚点 | 有有效高斯体, 全删 |
HAC++ 的创新:掩码感知码率
损失函数的三部分:
| 项 | 意义 | 大 → 怎么了 |
|---|---|---|
| 渲染质量 = L1 + (1-SSIM) + 正则化 | 渲染图不像真实照片 | |
| 总比特数 = Σ -log₂(p) | 文件大 | |
| 哈希网格自身比特 | 哈希表占存储 | |
| 压缩 vs 质量的权衡 | 大 → 文件小, 小 → 质量好 |
Compact3D [13] 的做法——两个超参:
L = L_render + λ·L_entropy + β·L_mask
多出 ,其中 (保留的高斯体比例)。
| 参数 | 控制 | 大 → | 小 → |
|---|---|---|---|
| 压缩率 | 文件小 | 质量好 | |
| 剪枝率 | 剪得多 | 保留多 |
问题: 和 不独立。 变时 的最优值也变,每次换 都要重调 。调参成本从 5 次训练变成 次。
HAC++ 的做法——一个超参自动解决:
将掩码直接嵌入码率计算,不需要额外的 :
# Compact3D:
bit = entropy(o, µ, σ) # 所有偏移都算比特
L_entropy = sum(bit)
total = L_render + λ·L_entropy + β·mean(m) # 额外惩罚
# HAC++:
bit = entropy(o, µ, σ) × m # m=0 → 比特=0
L_entropy = sum(bit) # 比特已含剪枝信息
total = L_render + λ·L_entropy # 没有 L_mask,没有 β
为什么一个 就够了:
# λ 大 → 总比特成本高 → 梯度推 m→0 来省比特 → 自动多剪
# λ 小 → 总比特成本低 → 梯度允许 m→1 保留 → 自动少剪
# λ 一个参数同时控制压缩 + 剪枝,两者天然协调
消融实验验证了这一设计的有效性:
| 方法 | BD-rate | 需调参数数 |
|---|---|---|
| HAC++(mask-aware rate) | 0.0% | 1() |
| W/ extra mask loss term(Compact3D 方式) | +9.6% | 2() |
多一个调不准的 比没有更差。
消融实验
| 消融项 | BD-rate | 说明 |
|---|---|---|
| W/o offset masking | +31.4% | 不剪高斯体 → 冗余占比特 |
| W/o anchor-level mask | +9.3% | 不剪锚点 → 无效锚点留着 |
| W/ extra mask loss term | +9.6% | 用 L_m 代替 mask-aware rate |
| HAC++ (完整) | 0.0% | 基准 |
F 哈希网格压缩
F 节在讲什么
训练时哈希网格参数是 float32,但每次前向 STE_binary 会拍成 用于计算 f^h。这些 最终也要存进文件。
F 节做的事情:把哈希网格的存储代价 加入损失函数,让训练时也能优化哈希网格的压缩率。
注意:STE 二值化本身不是 F 节引入的压缩手段。它是模型自带的计算方式(C.2 节 MLPc 用的 f^h 就是用 插值算的)。F 节只是引用这个事实,然后估算这些 的存储成本。
核心公式
| 符号 | 含义 |
|---|---|
| 哈希表中 “+1” 的数量 | |
| 哈希表中 “-1” 的数量 | |
| ”+1” 的出现频率 = |
这是香农公式对二值符号的直接应用——统计 +1 的比例,就知道算术编码需要多少比特。
代码实现(encodings.py:16-33):
def get_binary_vxl_size(binary_vxl):
ttl_num = binary_vxl.numel()
pos_num = torch.sum(binary_vxl) # +1 数量
hf = pos_num / ttl_num # 频率
hf = torch.clamp(hf, min=1e-6, max=1-1e-6)
bit = pos_num × (-log₂(hf)) + (ttl_num - pos_num) × (-log₂(1-hf))
bit += 32 # 存 hf 自身
return hf, bit
L_hash 的作用
越偏离 0.5,每参数平均比特越少:
| 每参数 bit | 说明 | |
|---|---|---|
| 0.5 | 1.0 | 正负各半,压不动 |
| 0.9 | 0.47 | +1 居多,有压缩空间 |
L_hash 是可微的,梯度能让哈希表的 float32 参数偏向某一方(比如尽量让参数 > 0 变成 +1),使 偏离 0.5,从而在算术编码时更省空间。
最终存储大小
存盘时 get_encoding_params() 取 序列,用 做算术编码写入 hash.b。
实际大小(Table VI, Mip-NeRF360):
| 总 size | 哈希网格 | |
|---|---|---|
| 0.5e-3 | 18.48 MB | 0.12 MB |
| 4e-3 | 8.34 MB | 0.09 MB |
实际压缩效果(Table VI, Mip-NeRF360)
| λ | 总 size | 哈希网格 | 占比 |
|---|---|---|---|
| 0.5e-3 | 18.48 MB | 0.12 MB | 0.6% |
| 4e-3 | 8.34 MB | 0.09 MB | 1.1% |
论文原文:
“To this end, we binarize the hash table to {−1, +1} using straight-through estimation (STE) and calculate the occurrence frequency hf of the symbol ‘+1’ to estimate its bit consumption”
编解码
编码时统计 写入 bitstream,解码时用 建立概率模型 → 算术解码还原 序列。
G 训练与编解码流程
总损失函数(训练时):
| 项 | 含义 |
|---|---|
| 渲染损失(保真度),同 Scaffold-GS | |
| 锚点属性的熵损失 | |
| 哈希网格的熵损失 | |
| 码率-保真度权衡超参数 |
编解码流程(顺序):
- 只有 走锚点内上下文(逐块顺序编解码)
- 和 只用 HAC 的 分布
- 所有概率估计不修改渲染分支,渲染速度和质量不受影响
Experiments
消融实验(Mip-NeRF360)
| 消融项 | BD-rate | 含义 |
|---|---|---|
| W/o AQM | N/A | 去掉自适应量化 → 保真度暴跌,无法算 BD-rate |
| W/o HAC information | +63.3% | 哈希网格置零 → 失去锚点间上下文 → 尺寸暴增 |
| W/o intra-anchor prob | +14.7% | 去掉锚点内上下文 → 概率估计不准 |
| W/o using GMM | +5.7% | GMM 改为简单拼接 → 融合效果变差 |
| W/ intra-anchor on | −0.1% | 对缩放 也做锚点内上下文 → 几乎无提升 |
| W/ intra-anchor on | +0.9% | 对偏移 也做 → 提升极小 |
| W/o offset masking | +31.4% | 去掉高斯级掩码 → 冗余高斯体占比特 |
| W/o anchor-level mask | +9.3% | 去掉锚点级掩码 → 无效锚点未被剪除 |
| W/ extra mask loss term | +9.6% | 改用额外 损失 → 次优 |
| W/o GPCC | +14.0% | 锚点位置不用 GPCC 压缩 → 更大 |
| HAC++ (完整) | 0.0% | 基准 |
三个关键结论:
- AQM 不可或缺 — 去掉后保真度完全不可接受
- 两种上下文协同 — HAC(锚点间)贡献最大(+63.3%),锚点内再进一步提升(+14.7%),GMM 融合再增加 5.7%
- 掩码策略有效 — 高斯级掩码贡献 +31.4%,锚点级掩码再贡献 +9.3%,mask-aware rate 优于额外
训练主流程
三阶段训练策略
| 迭代 | 阶段 | 做的事 |
|---|---|---|
| 0~3k | 纯 Scaffold-GS | 无量化、无哈希网格、无熵损失 |
| 3k~10k | 量化适应期 | 加均匀噪声模拟量化,3k~4k 冻结锚点生长 |
| 10k~30k | 完整 HAC++ | 哈希网格 + AQM + 锚点内上下文 + 掩码 + 熵损失 |
每个迭代做的事
Step 1:随机选视角
viewpoint_cam = scene.getTrainCameras().random()
Step 2:渲染前向(gaussian_renderer/__init__.py:25)
取当前视角可见锚点,进入 generate_neural_gaussians()。
输入数据准备(line 33-37):
anchor = pc.get_anchor[visible_mask] # x_a: 可见锚点位置
feat = pc._anchor_feat[visible_mask] # f^a: 锚点特征
grid_offsets = pc._offset[visible_mask] # o: 偏移量
grid_scaling = pc.get_scaling[visible_mask] # l: 缩放
binary_grid_masks = pc.get_mask[visible_mask] # m: 高斯级掩码
渲染路径(全部可见锚点,line 59-69):
仅在 step > 10000 时执行。先通过渲染路径获得量化后的锚点属性,再生成高斯体。
anchor → calc_interp_feat() → f^h [N, 96] ← 查哈希网格插值
↓
mlp_grid (两个 Linear+ReLU) → 225 维输出 ← 一个MLP完成全部预测
↓ torch.split (按预定位置切开)
┌──────────────────────────────────────────────────────┐
│ mean[50], scale[50], prob[50] → f^a 的 μ^s, σ^s, π^s │
│ mean_scaling[6], scale_scaling[6] → l 的 μ, σ │
│ mean_offsets[30], scale_offsets[30] → o 的 μ, σ │
│ Q_feat_adj[1], Q_scaling_adj[1], Q_offsets_adj[1] │
└──────────────────────────────────────────────────────┘
↓
AQM 步长调整: Q = Q₀ × (1 + Tanh(Q_adj)) ← 式(5)
↓
加均匀噪声模拟量化: feat += U(-0.5,0.5) × Q ← 式(4)训练分支
量化后的 继续往下传(line 141+),生成 3D 高斯体:
# 方向向量(视角相关)
ob_view = anchor - camera_center
ob_view = ob_view / ob_view.norm()
# 高斯体位置: 锚点 + 偏移 × 缩放
xyz = anchor + offset × scaling[:, :3]
# f^a + 方向 → 三个 MLP → 高斯体属性
opacity = mlp_opacity(feat, ob_view) # K 个不透明度
color = mlp_color(feat, ob_view) # K×3 RGB
cov = mlp_cov(feat, ob_view) # K×7 协方差
# 泼溅栅格化 → 2D 图像
image = tile_based_rasterizer(xyz, opacity, color, cov, camera)
熵路径(随机采样 5% 锚点,line 72-119):
仅在 step > 10000 时执行,采样 5% 锚点计算比特消耗,用于 。
choose_idx = rand() <= 0.05 ← 5% 采样
↓
同样的 f^h → mlp_grid → split 得到 μ^s, σ^s, π^s (HAC 预测)
↓
f^a → mlp_deform → μ^c, σ^c, π^c ← 锚点内上下文 式(7)
(Channel_CTX_fea, 5个chunk逐块自回归)
↓ softmax 融合权重
probs = softmax([π^s, π^c]) ← GMM 权重
↓
EG_mix_prob_2(f^a, μ^s, μ^c, σ^s, σ^c, probs) → bit_feat ← 式(8) GMM
entropy_gaussian(l, μ_l, σ_l) → bit_scaling ← 式(6) 单高斯
entropy_gaussian(o, μ_o, σ_o) × mask → bit_offsets ← 式(6) + 掩码
↓
平均比特/参数: bit_per_param = Σ(bit) / 总参数数
Step 3:计算损失
L_render = (1-λ_dssim)×L1 + λ_dssim×(1-SSIM) + 0.01×scaling_reg
# 仅 10k 后
L_loss = L_render + λ × (bit_per_param + L_hash / 总参数数)
对应论文式(13):
Step 4:反向传播
loss.backward() 执行后,梯度从损失函数向各参数回溯。下图展示了完整的梯度流及每条路径对应的训练效果:
loss (标量)
├──→ ∂L_render/∂opacity → ∂opacity/∂mlp_opacity_weights
│ → ∂opacity/∂f^a → ∂f^a/∂AQM → ∂f^a/∂[锚点_feat参数]
│ 效果: 渲染质量决定 f^a 的学习方向
│
├──→ λ·∂L_entropy/∂bit_feat
│ ├──→ ∂bit/∂GMM → ∂bit/∂μ^s,σ^s → grad(mlp_grid) → grad(f^h)
│ │ └──→ ∂bit/∂μ^c,σ^c → grad(mlp_deform)
│ │ 效果: 熵损失让 MLP 输出更准的 μ,σ → 概率更大 → 比特更少
│ │
│ └──→ ∂f^a/∂AQM_Q → 效果: AQM 自动调整量化步长
│
├──→ λ·∂L_entropy/∂bit_scaling → grad(mlp_grid) → grad(f^h)
│ 效果: l 的概率估计也依赖 f^h
│
├──→ λ·∂L_entropy/∂bit_offsets → grad(mlp_grid) → grad(f^h)
│ 效果: o 的概率估计也依赖 f^h
│
└──→ λ·∂L_hash/∂bit_hash → grad(STE_binary) → grad(H_float)
效果: 哈希网格参数被训练(STE直通,浮点更新)
梯度回到哈希网格的完整路径:
grad(f^h) — 渲染路径 + 熵路径的梯度在 f^h 处汇合
↓ CUDA grid_encode_backward (三线性插值反向传播)
grad(embeddings) — {-1,+1} 张量的梯度, 形状 [总槽位, D_h]
↓ STE_binary.backward
grad(self.params) — float32 张量的梯度, 形状 [总槽位, D_h]
↓
优化器: self.params -= lr × grad ← 这是真正被更新的参数
↓ 下次 forward 重新拍成 {-1,+1}
关键: 不存梯度信息。梯度直通(STE)保证浮点参数正常更新,前向时再重新二值化。
优化器步进(train.py:227):
gaussians.optimizer.step() # 更新所有可学习参数
gaussians.optimizer.zero_grad() # 清空梯度
更新的参数组包括:锚点属性、MLP 权重、哈希网格参数(各有独立学习率)。
Step 5:锚点密度控制(每 100 迭代)
training_statis() → 累积梯度和不透明度
adjust_anchor()
├→ anchor_growing: 梯度大的位置生成新锚点
└→ prune_anchor: 低不透明度的删掉
注意 3k~4k 暂停生长。
Step 6:30k 次重复 1-5
最后在 testing_iterations 执行:
estimate_final_bits() → 估算各组件 MB
conduct_encoding() → 写入 bitstream (.b 文件)
conduct_decoding() → 从 bitstream 还原验证
梯度流总结
渲染损失 → f^a / MLPs
熵损失 → mlp_grid / mlp_deform → f^h → STE → 哈希网格浮点参数
L_hash → 哈希网格浮点参数
训练驱动: 渲染保真度(↑) + 压缩率(↑) 的平衡, λ 控制 trade-off