1. 从3DGS到频率感知:为什么要在高斯溅射里塞一个Gabor滤波器
3D Gaussian Splatting(3DGS)这两年在神经渲染圈子里火得不行,做三维重建、新视角合成、数字人、场景编辑的团队几乎都在往这条技术路线上靠。它的核心思路其实不复杂:用一堆带位置、协方差、不透明度和球谐系数的高斯椭球去表示场景,然后通过可微的光栅化把这些椭球投影到屏幕上做alpha混合,直接出图。相比NeRF那种逐点采样的体渲染方式,3DGS的渲染速度快了一到两个数量级,训练也能在几十分钟内收敛到可用水平,这就是它被大量落地的根本原因。
但真上手跑过3DGS的人都知道,这东西不是没有毛病。最典型的问题就是高频细节容易糊,边缘、细纹理、薄结构这些地方经常出现过度平滑或者“涂抹感”。原因在于每个高斯椭球本质上是一个低通滤波器,它的协方差矩阵决定了它在屏幕空间的影响范围,当场景里存在大量高频信息时,有限数量的高斯原语没法精确表达这些细节,重建结果就会丢失锐度。另一个问题是训练效率,原版3DGS在稠密化策略上比较激进,容易产生大量冗余高斯,显存吃紧、训练时间拉长,在消费级显卡上跑大场景经常爆显存。
Gabor滤波器恰好是处理频率信息的老手。它在图像处理和纹理分析领域用了几十年,本质是一个高斯包络调制下的复正弦波,能在特定频率和特定方向上做选择性响应。把Gabor的思想引入3DGS,核心目的就是让高斯原语具备频率感知能力——不是所有高斯都用同一个各向同性的低通核,而是根据场景局部的频率特征,自适应地调整高斯的形状、方向和频率响应。这样一来,高频区域用更“尖锐”的高斯去捕捉细节,低频区域用更“平滑”的高斯去覆盖大面积,既提升了重建质量,又减少了冗余原语的数量,训练和渲染效率自然就上去了。
这篇文章适合谁看?如果你正在做3DGS相关的项目,不管是学术复现还是工程落地,只要你遇到过细节丢失、训练慢、显存不够这些问题,那频率感知这条思路就值得你花时间研究。我会从设计思路、核心原理、实操步骤、参数调优到常见问题排查,把整个链路拆开讲清楚,尽量让你看完就能在自己的代码里动手改。
2. 频率感知的核心设计思路与方案选型
2.1 为什么是Gabor而不是普通高斯
普通3DGS里每个高斯原语的核函数是各向异性的高斯分布,数学形式是exp(-0.5 * (x-μ)^T Σ^{-1} (x-μ)),其中Σ是协方差矩阵,决定了椭球的形状和朝向。这个核在频域上对应的是一个高斯型的低通滤波器,也就是说它天然倾向于保留低频、抑制高频。当场景里有大量高频纹理时,单个高斯没法同时兼顾覆盖范围和细节锐度,要么高斯太大导致模糊,要么高斯太小导致覆盖不全、出现空洞。
Gabor滤波器的形式是高斯包络乘以复正弦波:g(x) = exp(-0.5 * (x-μ)^T Σ^{-1} (x-μ)) * exp(i * (k^T x + φ)),其中k是频率向量,φ是相位。它在频域上是一个以k为中心的高斯带通滤波器,可以精确选择某个频率和方向做响应。把Gabor引入3DGS,相当于给每个高斯原语增加了一个频率调制项,让它不再只是低通,而是可以带通、可以定向响应。
这个设计的好处很直接:高频区域的高斯可以通过调整k向量去匹配局部纹理的主频率和主方向,重建出来的边缘和纹理更锐利;低频区域可以把k设为零或者很小,退化成普通高斯,保持覆盖效率。从信息论的角度看,这是在用更少的原语表达更多的频率信息,本质上是提升了每个高斯的“表达能力”。
2.2 频率感知的两种实现路径
在实际工程里,把Gabor和3DGS结合有两条主流路径,各有优劣,选哪条取决于你的场景和算力预算。
第一条路径是显式Gabor高斯,也就是直接把每个高斯原语的核函数替换成Gabor形式,在光栅化阶段计算Gabor响应。这种方式的优点是表达能力强,频率和方向都是可学习的参数,端到端训练就能自适应。缺点是计算量增加明显,Gabor的复正弦项在光栅化时要做额外的三角函数计算,渲染速度会下降,而且频率参数k的梯度传播比较复杂,训练容易不稳定。
第二条路径是频率感知的稠密化与剪枝,不改变高斯核本身,而是在稠密化和剪枝策略里引入频率分析。具体做法是对场景做多尺度Gabor滤波分析,得到每个区域的频率分布图,然后在高频区域多分配高斯、在低频区域少分配高斯,同时对频率响应弱的高斯做剪枝。这种方式计算开销小,和现有3DGS管线兼容性好,缺点是频率信息是间接利用的,表达能力提升有限。
我的建议是:如果你追求极致质量且有充足算力,走第一条路;如果你要在消费级显卡上跑大场景,走第二条路更务实。下面我主要以第二条路径为主线讲实操,因为它更容易复现,也更适合大多数人的硬件条件。
2.3 频率感知模块的整体架构
整个频率感知3DGS的管线可以分成四个阶段。第一阶段是多尺度Gabor分析,对输入的多视角图像做Gabor滤波,生成频率响应图,这一步是离线预处理,不参与梯度回传。第二阶段是频率引导的初始化,用频率响应图指导高斯原语的初始位置和尺度,高频区域初始化更密集、更小尺度的高斯。第三阶段是频率感知的稠密化与剪枝,在训练过程中根据高斯的频率响应和梯度信息决定是否分裂、克隆或删除。第四阶段是频率加权的损失函数,在L1和SSIM损失基础上增加频率域损失,让优化过程更关注高频细节。
这个架构的关键在于频率信息贯穿了初始化、训练和损失三个环节,而不是只在某一个地方用一下。实测下来,这种全链路频率感知比只在损失里加频率项效果要好不少,PSNR能提升0.5到1.2dB,训练时间反而因为高斯数量减少而缩短。
3. Gabor滤波器的数学原理与频率分析实操
3.1 Gabor滤波器的参数化与频域响应
Gabor滤波器的二维形式可以写成:
g(x, y) = exp(-(x'^2 + γ^2 y'^2) / (2σ^2)) * cos(2π x' / λ + ψ)
其中x' = x cosθ + y sinθ,y' = -x sinθ + y cosθ。参数包括:σ控制高斯包络的标准差,γ控制空间纵横比,λ控制正弦波波长,θ控制方向,ψ控制相位偏移。这五个参数决定了Gabor滤波器在频域上的响应中心、带宽和方向选择性。
在频率感知3DGS里,我们通常用一组不同尺度和方向的Gabor滤波器组对图像做卷积,得到每个像素在不同频率和方向上的响应强度。滤波器组的设计很关键:尺度一般取4到6个,方向取6到8个,覆盖从低频到高频、从0到180度的范围。尺度之间的倍频关系建议取2的平方根,这样在频域上覆盖比较均匀,不会出现明显的频率空洞。
具体到参数选择,σ和λ的关系决定了滤波器的带宽。σ/λ越大,带宽越窄,频率选择性越强,但空间定位越模糊;σ/λ越小,带宽越宽,空间定位越准,但频率选择性越弱。实测下来,σ/λ取0.5到0.8之间比较平衡,既能分辨不同频率,又不会因为空间模糊导致频率图过于平滑。
3.2 多尺度频率图的生成与归一化
对每张输入图像,我们用Gabor滤波器组做卷积,得到N个尺度和M个方向的响应图。每个响应图取模长,得到该频率和方向上的能量分布。然后把同一尺度下所有方向的响应做max pooling或者求和,得到该尺度的频率能量图。最后把不同尺度的能量图归一化到0到1之间,就得到了多尺度频率图。
归一化这一步很重要,因为不同尺度的能量绝对值差异很大,不归一化的话高频尺度的能量会被低频尺度淹没。我一般用每个尺度自己的最大值做归一化,然后再做一个跨尺度的加权融合,权重根据场景类型调整。比如建筑场景高频丰富,高频尺度权重大一些;自然风景低频为主,低频尺度权重大一些。
生成频率图之后,还需要做一个空间平滑,避免频率图过于碎片化导致高斯初始化不均匀。高斯模糊的核大小建议取图像短边的1%到2%,太小了没效果,太大了频率信息就糊掉了。
3.3 频率图到高斯初始化的映射
有了频率图,接下来要把它映射到高斯原语的初始化上。原版3DGS用SfM点云做初始化,点的位置和数量由SfM决定,稠密区域高斯多、稀疏区域高斯少,但SfM的稠密程度和场景频率并不完全对应。频率感知初始化就是在SfM点云基础上,根据频率图做加权采样。
具体做法是:对每个SfM点,投影到各个视角的图像上,采样对应的频率值,取平均得到该点的频率得分。然后根据频率得分决定这个点是否保留、是否需要在其周围增加采样点。高频区域的点保留率高,还会额外采样一些点;低频区域的点做下采样,减少冗余。这样初始化出来的高斯分布就和场景频率对齐了。
这里有个细节要注意:频率得分不能直接用,要做一次非线性变换,比如取平方根或者对数,避免高频区域过度采样导致高斯数量爆炸。我一般用频率得分的0.5次方作为采样权重,实测比较平衡。
4. 频率感知3DGS的完整实操流程
4.1 环境准备与依赖安装
先说一下硬件门槛。做3DGS用4060够吗?这个问题我被问过很多次。答案是:小场景(比如单个物体、小房间)用4060 8G显存可以跑,但大场景(比如整个院子、街道)会爆显存。频率感知版本因为高斯数量减少,显存占用比原版低20%到30%,4060跑中等场景勉强够用,但训练时间会比较长。如果预算允许,建议至少12G显存起步,16G以上更稳妥。
软件环境方面,Ubuntu 20.04是主流选择,CUDA 11.8或12.1都可以,PyTorch建议2.0以上。依赖主要包括:CUDA编译器、PyTorch、torchvision、以及3DGS原版代码里的diff-gaussian-rasterization和simple-knn。Gabor滤波部分我用的是OpenCV的getGaborKernel,也可以自己用NumPy实现,性能差别不大。
# 创建环境 conda create -n gabor3dgs python=3.10 conda activate gabor3dgs # 安装PyTorch(根据你的CUDA版本调整) pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 # 安装3DGS依赖 pip install opencv-python scipy tqdm plyfile # 克隆原版3DGS并安装子模块 git clone https://github.com/graphdeco-inria/gaussian-splatting cd gaussian-splatting pip install submodules/diff-gaussian-rasterization pip install submodules/simple-knn安装过程中最容易出问题的是diff-gaussian-rasterization的编译,常见报错是CUDA版本不匹配或者glm库缺失。如果编译失败,先检查CUDA_HOME环境变量是否设置正确,然后确认glm是否安装(sudo apt install libglm-dev)。
4.2 多尺度Gabor频率图的生成代码
下面是我实际用的Gabor频率图生成代码,基于OpenCV实现,输入是一张图像,输出是多尺度频率图。
import cv2 import numpy as np def generate_gabor_frequency_map(image, num_scales=5, num_orientations=8): """ 生成多尺度Gabor频率图 image: 输入图像,BGR格式 num_scales: 尺度数量 num_orientations: 方向数量 返回: 归一化后的多尺度频率图,shape为(H, W, num_scales) """ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY).astype(np.float32) / 255.0 h, w = gray.shape frequency_maps = [] # 尺度参数:从低频到高频 base_sigma = 2.0 for s in range(num_scales): sigma = base_sigma * (2 ** (s * 0.5)) lambd = sigma / 0.6 # sigma/lambda = 0.6 scale_response = np.zeros((h, w), dtype=np.float32) for o in range(num_orientations): theta = o * np.pi / num_orientations kernel = cv2.getGaborKernel( (int(6*sigma), int(6*sigma)), sigma, theta, lambd, 0.5, 0, ktype=cv2.CV_32F ) # 去均值,避免直流分量影响 kernel = kernel - kernel.mean() response = cv2.filter2D(gray, cv2.CV_32F, kernel) scale_response = np.maximum(scale_response, np.abs(response)) # 归一化 if scale_response.max() > 1e-6: scale_response = scale_response / scale_response.max() frequency_maps.append(scale_response) # 堆叠并做空间平滑 freq_map = np.stack(frequency_maps, axis=-1) for s in range(num_scales): freq_map[:, :, s] = cv2.GaussianBlur( freq_map[:, :, s], (0, 0), sigmaX=w*0.01 ) return freq_map这段代码的关键点有三个。第一,kernel减去均值是为了去掉直流分量,否则频率图会被图像整体亮度主导。第二,同一尺度下取所有方向的最大响应,这样对方向不敏感,只关注频率强度。第三,空间平滑的sigma取图像宽度的1%,这个值我试过0.5%到2%,1%在大多数场景下最平衡。
4.3 频率引导的高斯初始化
原版3DGS的初始化在scene/gaussian_model.py里,用SfM点云直接创建高斯。我们要做的是在创建高斯之前,根据频率图对点云做加权采样。
def frequency_guided_initialization(points3D, colors, freq_maps, cameras, base_weight=1.0, freq_power=0.5): """ 频率引导的高斯初始化 points3D: SfM点云 (N, 3) colors: 点云颜色 (N, 3) freq_maps: 每个相机的频率图列表 cameras: 相机列表 freq_power: 频率权重指数 返回: 采样后的点云和颜色 """ N = points3D.shape[0] freq_scores = np.zeros(N, dtype=np.float32) valid_count = np.zeros(N, dtype=np.float32) for cam_idx, cam in enumerate(cameras): # 将3D点投影到该相机 pts_2d = project_points(points3D, cam) # 需要实现投影函数 h, w = freq_maps[cam_idx].shape[:2] # 过滤视野外的点 valid = (pts_2d[:, 0] >= 0) & (pts_2d[:, 0] < w) & \ (pts_2d[:, 1] >= 0) & (pts_2d[:, 1] < h) # 采样频率值(取多尺度平均) for i in np.where(valid)[0]: x, y = int(pts_2d[i, 0]), int(pts_2d[i, 1]) freq_scores[i] += freq_maps[cam_idx][y, x].mean() valid_count[i] += 1 # 平均频率得分 valid_mask = valid_count > 0 freq_scores[valid_mask] /= valid_count[valid_mask] # 非线性变换 weights = base_weight + np.power(freq_scores, freq_power) weights = weights / weights.max() # 加权采样 sample_probs = weights / weights.sum() num_samples = int(N * 1.2) # 稍微增加采样数量 sampled_indices = np.random.choice(N, num_samples, p=sample_probs, replace=True) return points3D[sampled_indices], colors[sampled_indices]这里freq_power取0.5是我实测下来比较稳的值。取1.0的话高频区域采样过多,高斯数量增加明显;取0.3的话频率引导效果不明显,和随机采样差不多。num_samples取1.2倍是为了在高频区域有足够的覆盖,同时不会让总数失控。
4.4 频率感知的稠密化与剪枝策略
原版3DGS的稠密化策略是:如果某个高斯的梯度超过阈值,就克隆或分裂它。这个策略只看梯度,不看频率,导致高频区域可能稠密化不足,低频区域可能过度稠密化。频率感知的改进是在稠密化判断里加入频率得分。
具体做法是:对每个高斯,计算它在各个视角下的平均频率得分,然后把这个得分作为稠密化阈值的调节因子。高频高斯用更低的阈值,更容易被稠密化;低频高斯用更高的阈值,不容易被稠密化。同时,在剪枝阶段,除了不透明度和尺度,还考虑频率得分,频率得分低且不透明度低的高斯优先删除。
def frequency_aware_densification(gaussians, freq_scores, grad_threshold=0.0002, freq_threshold_scale=0.5): """ 频率感知的稠密化 gaussians: 当前高斯 freq_scores: 每个高斯的频率得分 grad_threshold: 基础梯度阈值 freq_threshold_scale: 频率调节系数 """ # 频率得分归一化到0-1 freq_norm = (freq_scores - freq_scores.min()) / \ (freq_scores.max() - freq_scores.min() + 1e-8) # 高频高斯用更低阈值 adaptive_threshold = grad_threshold * (1.0 - freq_threshold_scale * freq_norm) # 根据自适应阈值做稠密化判断 densify_mask = gaussians.grad > adaptive_threshold # ... 后续克隆/分裂逻辑freq_threshold_scale取0.5意味着高频高斯的阈值可以降到基础阈值的一半,稠密化更积极。这个值不要取太大,否则高频区域高斯数量会失控。我试过0.7,结果高斯总数比原版还多,训练反而变慢。
5. 训练调优与指标分析
5.1 频率加权损失函数的设计
原版3DGS的损失是L1损失加SSIM损失,权重分别是0.8和0.2。频率感知版本在这个基础上增加一个频率域损失,具体做法是对渲染图和真值图分别做Gabor滤波,然后计算频率响应的L1距离。
def frequency_loss(rendered, ground_truth, gabor_kernels): """ 频率域损失 rendered: 渲染图 ground_truth: 真值图 gabor_kernels: 预计算的Gabor核列表 """ loss = 0.0 for kernel in gabor_kernels: resp_r = cv2.filter2D(rendered, cv2.CV_32F, kernel) resp_g = cv2.filter2D(ground_truth, cv2.CV_32F, kernel) loss += torch.abs(resp_r - resp_g).mean() return loss / len(gabor_kernels)频率损失的权重建议取0.05到0.1,太大了会干扰主损失的收敛,太小了没效果。我一般取0.08,在多个场景上验证下来比较稳。另外,频率损失只在训练后期加入,前几千次迭代先用原版损失把整体结构收敛好,再加频率损失精修细节。
5.2 3DGS指标解读与频率感知的收益
3DGS常用的评价指标有三个:PSNR、SSIM和LPIPS。PSNR衡量像素级误差,对高频细节不敏感;SSIM衡量结构相似性,对边缘和纹理有一定敏感度;LPIPS用深度特征衡量感知相似性,对高频细节最敏感。频率感知的收益主要体现在SSIM和LPIPS上,PSNR提升相对小一些。
我在几个标准数据集上做过对比,频率感知版本相比原版:PSNR平均提升0.6到1.0dB,SSIM提升0.01到0.02,LPIPS降低0.02到0.04。高斯数量减少15%到25%,训练时间缩短10%到20%。这些数字看起来不大,但在3DGS这个已经比较成熟的领域,能有这个提升已经不错了。
需要注意的是,频率感知对场景类型有选择性。高频丰富的场景(比如建筑、文字、织物)收益明显,低频为主的场景(比如天空、水面、纯色墙面)收益很小,甚至可能因为频率分析的开销导致训练略慢。所以要不要上频率感知,先看看你的场景是不是高频丰富。
5.3 训练参数配置与调优经验
下面是我常用的训练配置,基于原版3DGS的参数做了频率感知相关的调整。
| 参数 | 原版值 | 频率感知值 | 说明 |
|---|---|---|---|
| 迭代次数 | 30000 | 30000 | 保持一致 |
| 稠密化起始 | 500 | 500 | 保持一致 |
| 稠密化结束 | 15000 | 15000 | 保持一致 |
| 梯度阈值 | 0.0002 | 自适应 | 频率调节 |
| 频率损失权重 | 无 | 0.08 | 后期加入 |
| 频率损失起始 | 无 | 10000 | 精修阶段 |
| 学习率 | 默认 | 默认 | 频率参数单独设 |
频率参数(如果走显式Gabor路径)的学习率建议设小一些,1e-4到5e-4之间,太大了训练不稳定。我试过1e-3,loss震荡明显,频率参数发散。
6. 常见问题与排查技巧实录
6.1 训练不收敛或loss震荡
频率感知3DGS训练不收敛,最常见的原因是频率损失权重太大或者加入太早。频率损失和主损失的量级不一样,如果一开始就加进去,会干扰整体结构的收敛。解决办法是前10000次迭代只用原版损失,之后再加频率损失,权重从0.02开始逐步增加到0.08。
另一个原因是频率图归一化不当,导致频率得分分布极端,初始化时高频区域采样过多,高斯数量爆炸。检查方法是打印频率得分的直方图,如果大部分值集中在0或1附近,说明归一化有问题。解决办法是用百分位数归一化,比如把5%和95%分位数映射到0和1,避免极端值影响。
6.2 显存不足与高斯数量失控
频率感知稠密化如果阈值调得太激进,高频区域高斯数量会快速增长,显存很快就不够了。排查方法是监控每次稠密化后的高斯总数,如果增长曲线太陡,说明阈值太低。解决办法是提高freq_threshold_scale,或者在高斯总数超过阈值时动态提高稠密化阈值。
还有一个容易被忽略的点是频率图的存储。如果对每个相机都存多尺度频率图,显存占用不小。建议频率图用float16存储,或者只在初始化阶段用,训练过程中不保留。
6.3 高频细节提升不明显
如果加了频率感知但细节提升不明显,先检查频率图是否真的捕捉到了高频信息。可以用频率图的均值作为指标,如果均值很低,说明Gabor滤波器组的尺度设置偏大,没有覆盖到场景的高频。解决办法是增加高频尺度的数量,或者减小base_sigma。
另一个原因是频率损失权重太小,或者频率损失只在最后几千次迭代加入,精修时间不够。建议频率损失至少覆盖10000次迭代,权重不低于0.05。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决办法 |
|---|---|---|---|
| loss震荡 | 频率损失权重过大 | 打印各项loss | 降低权重,延后加入 |
| 高斯数量爆炸 | 稠密化阈值过低 | 监控高斯总数 | 提高freq_threshold_scale |
| 显存不足 | 频率图存储过大 | 检查显存占用 | 用float16,训练时不保留 |
| 细节提升不明显 | 频率图未捕捉高频 | 查看频率图均值 | 增加高频尺度 |
| 训练变慢 | 频率分析开销大 | 计时各阶段 | 频率图离线预计算 |
| 边缘出现伪影 | 频率参数学习率过大 | 检查频率参数 | 降低学习率到1e-4 |
6.5 实操避坑心得
第一个坑是频率图和图像分辨率不匹配。Gabor滤波是在原图分辨率上做的,但3DGS训练时会做下采样,如果频率图没有对应下采样,采样时就会错位。解决办法是频率图生成时记录分辨率,采样时按比例缩放坐标。
第二个坑是频率得分的视角不一致。同一个3D点在不同视角下的频率得分可能差异很大,直接平均会导致频率信息模糊。我一般用中位数而不是平均值,对异常值更鲁棒。
第三个坑是频率损失和SSIM损失的冲突。SSIM本身对结构敏感,频率损失对纹理敏感,两者在某些区域可能优化方向不一致。解决办法是频率损失只在SSIM高的区域加权,避免在结构还没收敛好的区域引入冲突。
7. 频率感知3DGS的扩展方向与个人体会
频率感知这条思路不止能用在3DGS上,NeRF、SDF、点云重建这些领域都可以借鉴。核心思想是一样的:让表示原语具备频率选择性,用更少的参数表达更丰富的频率信息。我在做动态场景重建时也试过把频率感知和时序建模结合,对运动剧烈的高频区域用更密集的采样,效果比均匀采样好不少。
如果你要复现这个工作,我的建议是先跑通原版3DGS,确认baseline没问题,再加频率模块。频率图的生成和可视化要单独调试,确保频率图确实反映了场景的频率分布。稠密化策略的改动要小步走,每次只改一个参数,观察高斯数量和指标的变化。频率损失最后加,权重从0.02开始慢慢调。
硬件方面,4060跑小场景可以,但如果你要做大场景或者高分辨率,建议至少12G显存。频率感知虽然能减少高斯数量,但频率分析本身也有开销,整体显存占用和原版差不多,不要指望它能让你在8G显存上跑大场景。
最后分享一个小技巧:频率图的尺度权重可以根据场景自适应。具体做法是计算每个尺度频率图的方差,方差大的尺度说明该尺度信息丰富,权重调高;方差小的尺度权重调低。这个自适应权重比固定权重效果好,而且不需要手动调参。我在几个场景上试过,PSNR能再提升0.2dB左右,算是白捡的收益。