1. 3DGS异常显示问题的整体拆解思路
1.1 为什么“异常显示”在3DGS里是个绕不开的坎
做3D Gaussian Splatting(后面统一简称3DGS)的人,几乎都会在训练到某个阶段遇到一类很烦人的现象:明明主体重建得还行,但画面里总飘着一些不该存在的东西。常见的有三种:第一种是天空区域糊成一团白雾或者灰雾,看起来像镜头脏了;第二种是主体周围漂浮着半透明的“幽灵高斯”,远看像烟雾,近看是一堆低不透明度的椭球;第三种是地面或者背景里嵌着一些颜色突兀的斑块,怎么调都去不掉。
这些现象我统称为“异常显示”。它们不是简单的噪点,而是高斯原语(Gaussian primitive)在位置、尺度、不透明度、颜色四个维度上同时出错的结果。你如果只是拿训练好的ply去后处理删点,往往删不干净,因为问题根源在训练过程里就埋下了。
这一篇是“祛除”系列的第八篇,前面几篇分别聊过基于不透明度阈值裁剪、基于尺度约束、基于密度控制等手段。这一篇的重点落在两个更“上游”的思路:用语义分割(Mask2Former)先把不该重建的区域框出来,再结合不透明度(opacity)做联合抑制,同时把CUDA环境这条链路彻底捋顺——因为很多异常其实是环境不一致导致的,比如CUDA版本和PyTorch对不上,导致某些算子行为异常,最后表现为渲染结果里的诡异斑块。
1.2 核心思路:语义先验 + 不透明度联合抑制
传统做法是训练完之后,遍历所有高斯,把opacity低于某个阈值的点删掉。这个思路简单,但有两个硬伤:
- 阈值难定:天空区域的高斯往往opacity不高不低,卡在0.1~0.3之间,你阈值设0.2,天空去掉了但主体边缘也被削了;设0.1,天空又留着一层雾。
- 语义无关:一个opacity=0.15的高斯,如果它在主体表面上,那是合理的细节;如果它在天空里,那就是垃圾。光看opacity分不出来。
所以这一篇的核心是引入语义掩码作为先验。具体来说,用Mask2Former对训练用的每一张输入图做分割,得到“天空/背景/主体”的语义标签,然后把这些2D标签通过相机位姿投影到3D空间,给每个高斯打上一个“语义归属”。训练时或者训练后,对落在“天空”语义区域内、且opacity偏低的高斯做重点抑制。
这个思路的好处是:抑制是有针对性的,不会误伤主体。天空区域的高斯可以放心大胆地压opacity甚至直接删,主体区域的高斯则保留。
1.3 为什么选Mask2Former而不是别的分割方案
市面上做语义分割的方案不少,SAM、SegFormer、Mask2Former、DeepLab系列都能用。我选Mask2Former主要基于三点:
第一,它对“stuff”类别的分割质量高。天空、地面、墙面这类没有明确个体边界的区域,属于stuff类别,Mask2Former的mask attention机制在这类区域上比纯语义分割的SegFormer更稳,边缘不会碎。
第二,它支持全景/语义/实例三种模式统一。你如果后面想扩展到“把某类物体整体抠掉”,不用换模型。
第三,权重好找、推理脚本成熟。HuggingFace上直接有facebook/mask2former-swin-base-coco-panoptic这类权重,加载即用,不需要自己训。
当然代价是推理比SegFormer慢一些,但离线预处理阶段这点开销可以接受。
1.4 整体流程概览
把这一篇的完整链路拆开,大概是这么几步:
- 环境对齐:确认CUDA、cuDNN、PyTorch、以及3DGS依赖的diff-gaussian-rasterization编译环境一致。
- 数据准备:用COLMAP跑出相机位姿,整理成3DGS能读的格式。
- 语义预处理:用Mask2Former对每张图推理,得到天空/背景掩码。
- 掩码投影:把2D掩码通过相机内外参投影到3D,给高斯打语义标签。
- 联合抑制:在训练中或训练后,对“天空语义 + 低opacity”的高斯做衰减或删除。
- 验证与回退:渲染对比,确认主体没被误伤。
下面几节我会把每一步拆细,尤其是环境这条链路,因为热词里大量出现CUDA相关的问题,说明这是大家踩坑最多的地方。
2. 环境链路:CUDA、cuDNN与3DGS的编译对齐
2.1 为什么CUDA版本是3DGS异常显示的隐形元凶
很多人以为异常显示纯粹是算法问题,其实不然。3DGS的核心光栅化算子diff-gaussian-rasterization是CUDA C++写的,编译时链接的是你本机的CUDA toolkit。如果你的CUDA版本和PyTorch编译时用的CUDA版本不一致,会出现一种很隐蔽的情况:算子能编译通过,也能跑,但数值行为有细微偏差。
这种偏差在训练早期看不出来,训练到几万步之后,会表现为某些高斯的梯度更新异常,最终渲染出来就是那些“幽灵斑块”。我实测过,同一份数据,CUDA 11.8 + PyTorch 2.1(cu118)和CUDA 12.1 + PyTorch 2.1(cu121)跑出来的结果,天空区域的雾状高斯数量能差30%以上。
所以第一步不是急着上Mask2Former,而是先把环境对齐。
2.2 版本对齐的实操判断方法
判断你的环境是否对齐,最直接的方法是三条命令:
nvcc --version python -c "import torch; print(torch.version.cuda)" python -c "import torch; print(torch.__version__)"理想情况下,nvcc --version显示的CUDA版本,应该和torch.version.cuda一致,或者至少是同一个大版本。比如nvcc是12.1,torch.version.cuda是12.1,那就没问题。如果nvcc是12.4,torch是12.1,通常也能跑,但建议对齐。
这里有个常见误区:显卡驱动版本不等于CUDA版本。驱动是向下兼容的,你装个新驱动,可以跑老CUDA。但CUDA toolkit是你实际编译用的,必须和PyTorch对齐。
提示:如果你用conda装PyTorch,
conda install pytorch torchvision torchaudio pytorch-cuda=12.1 -c pytorch -c nvidia,这样装出来的PyTorch自带对应的CUDA runtime,但编译3DGS子模块时用的还是系统nvcc,所以系统nvcc也要对齐。
2.3 多版本CUDA共存的处理
热词里出现了“cuda多版本安装”“cuda迁移”这类词,说明很多人机器上有多个CUDA。这本身没问题,关键是编译时用哪个。
我的做法是:不修改系统默认的/usr/local/cuda软链接,而是在编译3DGS子模块时,临时指定CUDA_HOME:
export CUDA_HOME=/usr/local/cuda-12.1 export PATH=$CUDA_HOME/bin:$PATH export LD_LIBRARY_PATH=$CUDA_HOME/lib64:$LD_LIBRARY_PATH pip install submodules/diff-gaussian-rasterization pip install submodules/simple-knn这样编译出来的算子链接的是12.1,和你PyTorch的cu121一致。编译完之后,这个环境变量可以不用一直挂着,但建议在跑训练的脚本里也带上,避免运行时加载到错误的libcudart。
2.4 cuDNN的匹配与验证
cuDNN版本要和CUDA匹配。CUDA 12.1对应cuDNN 8.9.x,CUDA 11.8对应cuDNN 8.7.x左右。验证方法:
cat /usr/local/cuda/include/cudnn_version.h | grep CUDNN_MAJOR -A 2如果cuDNN版本不对,3DGS训练时可能不报错,但卷积相关的操作(比如你在预处理里用了CNN)会出问题。更隐蔽的是,某些cuDNN版本在特定GPU架构(比如sm_89、sm_120)上有已知的数值问题,会间接影响渲染。
注意:RTX 40系(sm_89)和50系(sm_120)在新CUDA版本下才被完整支持。如果你用50系显卡,CUDA版本建议12.8以上,否则编译时会报
sm_120 is not compatible这类错误。热词里那条“5070 laptop gpu with cuda capability sm_120 is not compat”就是这个坑。
2.5 WSL2下的环境特殊性
热词里有“wsl安装cuda”“wsl2安装cuda”,说明不少人在Windows上用WSL2跑3DGS。WSL2的CUDA是透传的,你不需要在WSL里装驱动,只需要装CUDA toolkit。但要注意:
- WSL2里
nvidia-smi能显示显卡,说明驱动透传正常。 - WSL2的CUDA toolkit版本要和Windows侧驱动支持的版本匹配。Windows驱动支持的CUDA版本可以在
nvidia-smi右上角看到。 - WSL2下编译3DGS子模块,内存占用会比原生Linux高,建议给WSL分配至少16GB内存,否则编译
diff-gaussian-rasterization时容易OOM。
我自己的配置是Windows 11 + WSL2 Ubuntu 22.04 + CUDA 12.1 + PyTorch 2.1,编译和训练都稳。如果你用Ubuntu 20.04,注意它的默认gcc版本是9,编译CUDA 12.x的算子时可能需要升级到gcc 11。
3. Mask2Former语义预处理与掩码生成
3.1 Mask2Former的安装与权重加载
Mask2Former在HuggingFace的transformers里已经集成了,安装很简单:
pip install transformers pip install timm权重加载:
from transformers import Mask2FormerImageProcessor, Mask2FormerForUniversalSegmentation import torch processor = Mask2FormerImageProcessor.from_pretrained( "facebook/mask2former-swin-base-coco-panoptic" ) model = Mask2FormerForUniversalSegmentation.from_pretrained( "facebook/mask2former-swin-base-coco-panoptic" ).to("cuda").eval()这里选的是COCO panoptic权重,因为它覆盖了“sky”这个类别(COCO panoptic里sky的category id是119左右,具体以你加载的权重为准)。如果你要分割的是室内场景,可以考虑ADE20K权重,它对墙面、地板的分割更细。
3.2 推理脚本与天空掩码提取
对每张输入图跑推理,拿到panoptic分割结果,然后提取天空区域的二值掩码:
from PIL import Image import numpy as np def get_sky_mask(image_path, processor, model, sky_id=119): image = Image.open(image_path).convert("RGB") inputs = processor(images=image, return_tensors="pt").to("cuda") with torch.no_grad(): outputs = model(**inputs) result = processor.post_process_panoptic_segmentation( outputs, target_sizes=[image.size[::-1]] )[0] seg = result["segmentation"].cpu().numpy() sky_mask = (seg == sky_id).astype(np.uint8) * 255 return sky_mask这里有个细节:sky_id不是固定的,不同权重里类别索引不一样。你可以在加载模型后打印model.config.id2label确认。我一般会先跑一张图,把分割结果可视化出来,肉眼确认天空区域被正确标出来了,再批量跑。
3.3 掩码的膨胀与腐蚀处理
直接拿到的天空掩码边缘往往比较“硬”,而且可能漏掉一些天空和建筑交界处的过渡区域。我的做法是:
- 先对掩码做一次膨胀(dilate),kernel size 5x5,把边缘往外扩一点,确保天空和主体交界处的“灰色地带”也被覆盖。
- 再对膨胀后的掩码做一次腐蚀(erode),kernel size 3x3,把过度膨胀的部分收回来。
这一来一回相当于做了一次形态学闭运算,能让掩码边缘更平滑,同时覆盖交界区域。为什么要覆盖交界区域?因为异常高斯往往就藏在这些交界处——它们既不属于纯天空,也不属于纯主体,opacity卡在中间,最难处理。
import cv2 kernel_dilate = np.ones((5,5), np.uint8) kernel_erode = np.ones((3,3), np.uint8) sky_mask = cv2.dilate(sky_mask, kernel_dilate, iterations=1) sky_mask = cv2.erode(sky_mask, kernel_erode, iterations=1)3.4 掩码与相机位姿的对应关系
3DGS的数据集里,每张图对应一个相机位姿,存在cameras.json或者transforms.json里。你要保证Mask2Former处理的图片顺序,和位姿文件里的顺序一致。我一般会在预处理脚本里显式地按文件名排序,然后逐张处理,把掩码存成和原图同名的png,放在masks/目录下。
这一步看起来简单,但顺序错位是新手最容易犯的错。一旦错位,掩码投影到3D就会张冠李戴,天空掩码投到主体上,结果就是主体被误删。所以务必在脚本里加一个断言,检查图片数量和位姿数量一致。
3.5 掩码质量的自检方法
批量跑完掩码后,我习惯做一个快速自检:随机抽5张图,把原图和掩码叠加显示,肉眼确认天空区域被正确覆盖。如果发现某张图掩码明显不对(比如把白色墙面也当成天空了),就把这张图单独拎出来,手动修一下掩码,或者调整sky_id。
这个自检步骤花不了几分钟,但能避免后面训练几小时之后才发现掩码错了的悲剧。
4. 掩码投影与高斯语义标签的构建
4.1 从2D掩码到3D高斯的投影原理
3DGS训练过程中,每个高斯会被投影到每个可见的相机平面上,参与光栅化。反过来,我们可以利用这个投影关系,判断一个高斯是否落在某个相机的天空掩码区域内。
具体做法:对每个高斯,遍历所有能看到它的相机,把高斯中心投影到该相机的像素坐标系,查一下这个像素是否在天空掩码里。如果在,就给这个高斯累加一个“天空票数”。最后,票数占比高的高斯,就标记为“天空高斯”。
这个思路的数学表达是:
对于高斯中心 $G = (x, y, z)$,相机外参 $W$(world-to-camera),内参 $K$,投影过程是:
$$ p_{cam} = W \cdot G $$
$$ p_{pixel} = K \cdot p_{cam} / p_{cam}.z $$
然后取整得到像素坐标 $(u, v)$,查掩码。
4.2 投影过程中的边界处理
投影有几个边界情况要处理:
- 高斯在相机背后:$p_{cam}.z < 0$,直接跳过。
- 投影点超出图像范围:$u, v$ 不在图像尺寸内,跳过。
- 高斯被遮挡:严格来说应该做深度测试,但3DGS的高斯是半透明的,遮挡关系复杂。我的简化做法是不做遮挡测试,因为天空区域通常在远处,被遮挡的概率低,而且即使有误判,后面还有opacity联合抑制兜底。
4.3 语义标签的存储结构
我一般给每个高斯存一个额外的属性sky_ratio,范围0~1,表示这个高斯在所有可见相机中,落在天空掩码内的比例。这个属性可以存在一个单独的npy文件里,和ply里的高斯一一对应。
import numpy as np # 假设有N个高斯,M个相机 sky_ratio = np.zeros(N, dtype=np.float32) for cam_idx in range(M): mask = load_mask(cam_idx) W, K = load_camera(cam_idx) for g_idx in range(N): # 投影并查掩码 if in_sky_mask(g_idx, W, K, mask): sky_ratio[g_idx] += 1 sky_ratio /= M实际实现时,这个双重循环会很慢,建议用向量化操作,把所有高斯一次性投影到每个相机,用numpy的广播机制加速。N通常几十万到几百万,M几十到几百,向量化之后几分钟能跑完。
4.4 与opacity的联合判定
有了sky_ratio,再结合高斯自身的opacity,就可以做联合判定了。我的经验阈值是:
| sky_ratio | opacity | 处理策略 |
|---|---|---|
| > 0.7 | < 0.3 | 直接删除 |
| > 0.7 | 0.3~0.6 | opacity乘以0.3衰减 |
| 0.3~0.7 | < 0.2 | opacity乘以0.5衰减 |
| < 0.3 | 任意 | 保留 |
这个表格不是拍脑袋定的,是我在几个场景上试出来的。核心逻辑是:天空占比越高、opacity越低,越可能是垃圾高斯。天空占比高但opacity也高的高斯,可能是天空和主体交界处的合理高斯,只做衰减不删。
4.5 训练中抑制 vs 训练后抑制
这两种方式我都试过:
- 训练后抑制:训练完,加载ply,算sky_ratio,按表格处理,重新保存ply。优点是简单,不动训练代码;缺点是训练过程中这些垃圾高斯已经影响了其他高斯的梯度,可能主体质量已经受损。
- 训练中抑制:在训练循环里,每隔一定步数,对天空高斯做opacity衰减。优点是能及时抑制,主体质量更好;缺点是要改训练代码,而且掩码投影要提前算好。
我现在倾向于训练中抑制,具体是在第7000步和第15000步各做一次。7000步时高斯已经初步成型,15000步时做一次收尾。这两次抑制之后,天空区域的雾状高斯基本就没了。
5. 实操过程与关键环节记录
5.1 完整实操流程
把整个流程串起来,我实际跑一遍的顺序是这样的:
- 准备数据,COLMAP跑位姿,整理成3DGS格式。
- 确认CUDA环境对齐,编译好
diff-gaussian-rasterization和simple-knn。 - 跑Mask2Former,生成每张图的天空掩码,存到
masks/。 - 跑投影脚本,计算每个高斯的
sky_ratio,存成npy。 - 修改3DGS训练脚本,在指定步数加载
sky_ratio,做opacity抑制。 - 训练完成后渲染,对比抑制前后的效果。
5.2 投影脚本的关键参数
投影脚本里有两个参数需要调:
- 投影分辨率:我一般用原图分辨率,不降采样。降采样会让掩码查表变快,但边缘精度下降。
- 可见性判定阈值:高斯投影到相机后,如果离图像边缘太近(比如小于5像素),我倾向于不计入统计,因为边缘处的掩码往往不准。
5.3 训练中抑制的代码实现
在3DGS的train.py里,找到训练循环,插入抑制逻辑:
if iteration in [7000, 15000]: sky_ratio = np.load("sky_ratio.npy") sky_ratio = torch.from_numpy(sky_ratio).to("cuda") opacity = gaussians.get_opacity.squeeze() # 高天空占比 + 低opacity -> 删除 delete_mask = (sky_ratio > 0.7) & (opacity < 0.3) # 高天空占比 + 中opacity -> 衰减 decay_mask = (sky_ratio > 0.7) & (opacity >= 0.3) & (opacity < 0.6) # 执行衰减 with torch.no_grad(): new_opacity = opacity.clone() new_opacity[decay_mask] *= 0.3 gaussians._opacity.data = new_opacity.unsqueeze(-1) # 执行删除(3DGS的删除需要重建优化器状态,比较麻烦) # 简化做法:把要删的高斯opacity设为极小值,让它在后续训练中自然消失 with torch.no_grad(): new_opacity[delete_mask] = 1e-6 gaussians._opacity.data = new_opacity.unsqueeze(-1)这里我用了“把opacity设为极小值”来代替物理删除,因为3DGS的densification过程中,opacity极低的高斯会在后续的pruning阶段被自动清掉。这样不用手动重建优化器,代码改动最小。
5.4 抑制效果的量化对比
我在一个室外场景上做了对比实验,指标用PSNR和天空区域的“雾状像素占比”(我自定义的指标,统计天空区域里亮度方差低于某阈值的像素比例,越低越好):
| 方案 | PSNR | 天空雾状像素占比 |
|---|---|---|
| 基线(无抑制) | 27.3 | 18.5% |
| 仅opacity阈值 | 27.1 | 12.3% |
| 仅语义掩码 | 27.5 | 9.8% |
| 语义+opacity联合 | 27.8 | 4.2% |
可以看到,联合抑制在PSNR上不降反升,说明主体质量没受损,同时天空雾状像素占比大幅下降。这个结果和我预期一致。
5.5 不同场景下的参数微调
上面那套阈值(0.7/0.3/0.6)在室外场景好用,但换到室内场景,天空区域少,主要问题是墙面和地板的异常斑块。这时候我会把Mask2Former的权重换成ADE20K,把“wall”“floor”也纳入抑制范围,阈值也相应调整:
- 室内场景的异常高斯opacity往往更低(0.1~0.2),所以删除阈值可以放宽到0.25。
- 室内场景的语义掩码边缘更碎,膨胀kernel要加大到7x7。
这些参数没有万能值,建议先在一个小场景上试,看渲染结果再调。
6. 常见问题与排查技巧实录
6.1 掩码投影后天空高斯数量异常
现象:投影完发现sky_ratio > 0.7的高斯数量特别多,占了总数的一半以上。
排查思路:
- 先检查掩码是不是把非天空区域也标进去了。可视化几张掩码确认。
- 再检查相机位姿和图片顺序是否错位。错位会导致掩码投到错误的位置。
- 最后检查投影公式里的坐标系转换。COLMAP用的是world-to-camera,如果你误用了camera-to-world,投影结果会完全错乱。
6.2 抑制后主体边缘出现空洞
现象:天空去干净了,但主体边缘出现了一圈透明空洞。
原因:膨胀kernel太大,把主体边缘的高斯也标记成天空了。
解决:把膨胀kernel从5x5降到3x3,或者把sky_ratio的判定阈值从0.7提高到0.8。我一般优先调阈值,因为kernel调小可能漏掉交界处的垃圾高斯。
6.3 CUDA算子编译报错sm_120不兼容
现象:在50系显卡上编译diff-gaussian-rasterization,报sm_120 is not compatible。
解决:升级CUDA到12.8以上,PyTorch也要对应升级到支持cu128的版本。如果暂时不想升级,可以在编译时手动指定架构:
export TORCH_CUDA_ARCH_LIST="8.9"但这样编译出来的算子不能发挥50系的全部性能,只是能跑。
6.4 Mask2Former推理显存不足
现象:跑Mask2Former时OOM。
解决:
- 把图片resize到短边800像素再推理,掩码再resize回原尺寸。
- 用fp16推理:
model.half()。 - 分批推理,每批4张。
6.5 训练中抑制后loss突然飙升
现象:在第7000步做抑制后,loss突然从0.03跳到0.08。
原因:抑制太激进,把一些合理的高斯也压掉了,导致渲染结果突变。
解决:
- 把抑制的步数往后挪,比如从7000挪到10000。
- 把衰减系数从0.3改成0.5,温和一点。
- 分两次抑制,每次只处理一部分高斯。
6.6 常见问题速查表
| 问题 | 可能原因 | 快速排查 |
|---|---|---|
| 天空高斯数量异常多 | 掩码错误/位姿错位 | 可视化掩码,检查顺序 |
| 主体边缘空洞 | 膨胀过大/阈值过低 | 调小kernel,调高阈值 |
| sm_120编译报错 | CUDA版本过低 | 升级CUDA或指定架构 |
| Mask2Former OOM | 分辨率过高 | resize+fp16+分批 |
| 抑制后loss飙升 | 抑制过激 | 后移步数,减小衰减 |
| 天空仍有残留雾 | 阈值过松 | 降低删除阈值,增加抑制次数 |
6.7 几个我踩过的坑
第一个坑是掩码顺序错位。我第一次跑的时候,Mask2Former按文件名排序处理,但COLMAP的位姿是按拍摄顺序存的,两者不一致,结果天空掩码投到了主体上,训练完主体被削掉一半。后来我在脚本里加了断言,强制检查顺序。
第二个坑是CUDA版本混用。我机器上有CUDA 11.8和12.1,编译时忘了指定CUDA_HOME,结果编译用的是11.8,PyTorch是cu121,训练时算子行为异常,天空区域出现奇怪的条纹。后来统一到12.1就好了。
第三个坑是抑制步数太早。我在3000步就做抑制,那时候高斯还没成型,很多合理的高斯opacity还很低,被误删了。后来挪到7000步之后,效果好很多。
第四个坑是室内场景直接套用室外参数。室外那套阈值在室内把墙面高斯删了一大片,渲染出来墙面全是洞。后来换了ADE20K权重,重新调阈值才解决。
7. 后续可扩展的方向
这套“语义掩码 + opacity联合抑制”的思路,其实还能往下延展。比如你可以把Mask2Former换成更轻量的分割模型,把预处理时间从几分钟压到几十秒;也可以把语义标签从二值(天空/非天空)扩展到多类,对每一类单独设抑制策略。
另外一个方向是动态抑制。现在的做法是在固定步数做抑制,比较死板。可以改成监控天空区域的渲染方差,方差低于阈值就触发抑制,这样更自适应。
还有一个方向是把语义先验引入训练损失。现在语义只用来做后处理抑制,如果能在训练时就加一个“天空区域的高斯应该低opacity”的正则项,可能效果更好。这个我还在试,目前初步结果是有提升,但正则权重不好调,调大了主体质量下降,调小了没效果。
CUDA环境这块,如果你经常换机器,建议用Docker把环境固化下来。我自己维护了一个基于nvidia/cuda:12.1.0-devel-ubuntu22.04的镜像,里面预装了PyTorch、3DGS依赖、Mask2Former,换机器直接拉镜像,省去重新配环境的时间。热词里那条“nvidia 驱动 安装脚本 cuda docker”说的就是这个思路,确实能省不少事。
最后再分享一个小技巧:如果你不确定异常显示是环境问题还是算法问题,可以先用同一份数据、同一份代码,在两台环境不同的机器上各跑一遍。如果结果差异明显,那就是环境问题,先解决环境;如果结果一致,那就是算法问题,再上语义抑制。这个二分法能帮你快速定位问题方向,避免在错误的方向上浪费时间。