From 29b974ed470e827019bc4ce7d789e26bb6f7cb3a Mon Sep 17 00:00:00 2001 From: liuzhi Date: Thu, 9 May 2024 20:17:55 +0800 Subject: [PATCH] add comments --- run_code.txt | 81 ++++++++++ scene/__init__.py | 41 ++++- scene/dataset_readers.py | 60 ++++++-- scene/gaussian_model.py | 202 +++++++++++++++++-------- submodules/diff-gaussian-rasterization | 2 +- train.py | 49 +++--- utils/general_utils.py | 42 ++++- utils/sh_utils.py | 5 + 8 files changed, 380 insertions(+), 102 deletions(-) create mode 100644 run_code.txt diff --git a/run_code.txt b/run_code.txt new file mode 100644 index 0000000..9f59708 --- /dev/null +++ b/run_code.txt @@ -0,0 +1,81 @@ +Running: +# Train with train/test split +python train.py --source_path --model_path <保存路径> --eval +python train.py --source_path /media/liuzhi/b4608ade-d2e0-430d-a40b-f29a8b22cb8c/Dataset/3DGS_Dataset/湘家荡 --model_path output/xiangjiadang --eval --resolution 1 + +--source_path / -s:COLMAP 或合成 Synthetic NeRF data set的源目录的路径。COLMAP类型包含 images/, sparse/0 +--model_path / -m:训练模型的存储路径,默认为 output/ +--images / -i:COLMAP 图像的替代子目录,默认为 images +--eval:训练时默认使用全部图片,--eval可以在训练时按照MipNeRF360-style划分 training/test,用于 evaluation +--resolution / -r:指定训练前加载图像的分辨率。如果是 1, 2, 4 or 8 则使用原始分辨率的 1/2, 1/4 or 1/8。 + 对于所有其他值,会将图像宽度调整到给定数字,同时保持图像宽高比; + 如果未设置并且输入图像宽度超过 1.6K 像素,则输入将宽度自动缩放到 1.6k + +--data_device:指定源图像数据在训练时的放置位置,默认使用cuda,如果在大型/高分辨率数据集上进行训练,建议使用cpu,减少显存占用,但训练速度会变慢 + +--white_background / -w:添加此标志以使用白色背景而不是黑色(默认),用于评估 NeRF Synthetic dataset +--sh_degree:球谐函数的阶数(不大于 3),默认为 3 +--convert_SHs_python:添加此标志以使用 PyTorch 而不是论文提出的pipeline计算 SH系数 的forward and backward +--convert_cov3D_python:添加此标志以使用 PyTorch 而不是论文提出的pipeline计算 3D协方差 的forward and backward + +--debug:如果遇到错误,请启用调试模式。如果光栅化器失败,dump则会创建一个文件,您可以在问题中将其转发给我们,以便我们查看。 +--debug_from:调试速度慢。可以指定一个迭代次数(从 0 开始),指定数字之前的迭代会是活动状态 + +--iterations:训练的总迭代次数,默认为 30_000 + +--ip:启动 GUI 服务器的 IP,默认为 127.0.0.1 +--port:GUI 服务器的端口,默认为 6009 + +--test_iterations:训练脚本在测试集上计算 L1 和 PSNR 的分隔迭代次数,默认为 7000, 30000 +--save_iterations:训练脚本保存高斯模型的分隔迭代次数,默认为 7000, 30000 +--checkpoint_iterations:存储checkpoint的分隔迭代次数,以后续继续新联,保存在model directory中 +--start_checkpoint:保存的checkpoint的路径,以继续训练 + +--quiet:此标志用于不写入任何文本到标准输出管道 + +--feature_lr:球谐函数的学习率,默认为 0.0025 +--opacity_lr:不透明度的学习率,默认为 0.05 +--scaling_lr:尺度的学习率,默认为 0.005 +--rotation_lr:旋转四元数的学习率,默认为 0.001 + +--position_lr_max_steps:位置学习率从初始值到最终值的步数(从 0 开始),默认为 30_000 +--position_lr_init:位置学习率的初始值,默认为 0.00016 +--position_lr_final:位置学习率的最终值,默认为 0.0000016 +--position_lr_delay_mult:位置学习率的乘数,默认为 0.01 + +--densify_from_iter:densification开始的迭代次数,默认为 500 +--densify_until_iter:densification结束的迭代次数,默认为 15_000 +--densify_grad_threshold:决定是否应基于 2D 位置梯度对点进行densification的限制,默认为0.0002 +--densification_interval:densify的频率,默认为 100(每迭代100次进行1次) + +--opacity_reset_interval:重置不透明度的频率,默认为 3_000 + +--lambda_dssim:SSIM 对总损失的影响,从 0 到 1,默认为0.2 + +--percent_dense:点必须超过场景范围的百分比才能强制致密, (0--1),默认为0.01 + +#-------------------------------------------------------------------------- +Evluation: +# Generate renderings +python render.py -m + +--model_path / -m:为其创建renderings的以训练模型的路径 +--skip_train:跳过渲染training set +--skip_test:跳过渲染test set +--quiet:不写入任何文本到标准输出管道 + +以下参数会从用于训练的model path中自动读取。但可覆写它们 +--source_path / -s:COLMAP 或合成 Synthetic NeRF data set的源目录的路径。 +--images / -i:COLMAP 图像的替代子目录,默认为 images +--eval +--resolution / -r +--white_background / -w +--convert_SHs_python +--convert_cov3D_python + +#-------------------------------------------------------------------------- +# Compute error metrics on renderings +python metrics.py -m + +--model_paths / -m:应计算metrics的model paths的分隔列表 + diff --git a/scene/__init__.py b/scene/__init__.py index 2b31398..8622629 100644 --- a/scene/__init__.py +++ b/scene/__init__.py @@ -19,27 +19,39 @@ from arguments import ModelParams from utils.camera_utils import cameraList_from_camInfos, camera_to_JSON class Scene: - + """ + Scene 类用于管理场景的3D模型,包括相机参数、点云数据和高斯模型的初始化和加载 + """ gaussians : GaussianModel def __init__(self, args : ModelParams, gaussians : GaussianModel, load_iteration=None, shuffle=True, resolution_scales=[1.0]): - """b - :param path: Path to colmap scene main folder. """ - self.model_path = args.model_path - self.loaded_iter = None - self.gaussians = gaussians + 初始化场景对象 + :param args: 包含模型路径和源路径等模型参数 + :param gaussians: 高斯模型对象,用于场景点的3D表示 + :param load_iteration: 指定加载模型的迭代次数,如果不为None且为-1,则在输出文件夹下的point_cloud/文件夹下搜索迭代次数最大的模型,且不为-1,则加载指定迭代次数的 + :param shuffle: 是否在训练前打乱相机列表 + :param resolution_scales: 分辨率比例列表,用于处理不同分辨率的相机 + """ + self.model_path = args.model_path # 模型文件保存路径 + self.loaded_iter = None # 已加载的迭代次数 + self.gaussians = gaussians # 高斯模型对象 + # 检查并加载已有的训练模型 if load_iteration: + # 不为None if load_iteration == -1: + # 且为-1,则在输出文件夹下的point_cloud/文件夹下搜索迭代次数最大的模型,记录最大迭代次数 self.loaded_iter = searchForMaxIteration(os.path.join(self.model_path, "point_cloud")) else: + # 不为-1,则加载指定迭代次数的 self.loaded_iter = load_iteration print("Loading trained model at iteration {}".format(self.loaded_iter)) - self.train_cameras = {} - self.test_cameras = {} + self.train_cameras = {} # 用于训练的相机参数 + self.test_cameras = {} # 用于测试的相机参数 + # 判断数据集类型是COLMAP的输出,还是Blender得输出,并从中加载场景信息 if os.path.exists(os.path.join(args.source_path, "sparse")): scene_info = sceneLoadTypeCallbacks["Colmap"](args.source_path, args.images, args.eval) elif os.path.exists(os.path.join(args.source_path, "transforms_train.json")): @@ -48,6 +60,7 @@ class Scene: else: assert False, "Could not recognize scene type!" + # loaded_iter = None,模型还未训练过, if not self.loaded_iter: with open(scene_info.ply_path, 'rb') as src_file, open(os.path.join(self.model_path, "input.ply") , 'wb') as dest_file: dest_file.write(src_file.read()) @@ -68,6 +81,7 @@ class Scene: self.cameras_extent = scene_info.nerf_normalization["radius"] + # 根据resolution_scales加载不同分辨率的训练和测试位姿 for resolution_scale in resolution_scales: print("Loading Training Cameras") self.train_cameras[resolution_scale] = cameraList_from_camInfos(scene_info.train_cameras, resolution_scale, args) @@ -75,18 +89,29 @@ class Scene: self.test_cameras[resolution_scale] = cameraList_from_camInfos(scene_info.test_cameras, resolution_scale, args) if self.loaded_iter: + # 直接读取对应(已经迭代出来的)场景 self.gaussians.load_ply(os.path.join(self.model_path, "point_cloud", "iteration_" + str(self.loaded_iter), "point_cloud.ply")) else: + # loaded_iter = None,模型还未训练过,调用GaussianModel.create_from_pcd从scene_info.point_cloud中建立模型 self.gaussians.create_from_pcd(scene_info.point_cloud, self.cameras_extent) def save(self, iteration): + """ + 保存当前迭代下的3D高斯模型点云。 + iteration: 当前的迭代次数 + """ point_cloud_path = os.path.join(self.model_path, "point_cloud/iteration_{}".format(iteration)) self.gaussians.save_ply(os.path.join(point_cloud_path, "point_cloud.ply")) def getTrainCameras(self, scale=1.0): + """ + 获取指定分辨率比例的训练相机列表 + scale: 分辨率比例 + return: 指定分辨率比例的训练相机列表 + """ return self.train_cameras[scale] def getTestCameras(self, scale=1.0): diff --git a/scene/dataset_readers.py b/scene/dataset_readers.py index 2a6f904..75cb883 100644 --- a/scene/dataset_readers.py +++ b/scene/dataset_readers.py @@ -66,32 +66,47 @@ def getNerfppNorm(cam_info): return {"translate": translate, "radius": radius} def readColmapCameras(cam_extrinsics, cam_intrinsics, images_folder): - cam_infos = [] + cam_infos = [] # 初始化用于存储相机信息的列表 + + # 遍历所有相机的外参 for idx, key in enumerate(cam_extrinsics): + # 动态显示读取相机信息的进度 sys.stdout.write('\r') # the exact output you're looking for: sys.stdout.write("Reading camera {}/{}".format(idx+1, len(cam_extrinsics))) sys.stdout.flush() - extr = cam_extrinsics[key] - intr = cam_intrinsics[extr.camera_id] - height = intr.height - width = intr.width + # 获取当前相机的外参和内参 + extr = cam_extrinsics[key] # 当前相机的外参 + intr = cam_intrinsics[extr.camera_id] # 根据外参中的camera_id找到对应的内参 + height = intr.height # 相机图片的高度 + width = intr.width # 相机图片的宽度 - uid = intr.id - R = np.transpose(qvec2rotmat(extr.qvec)) - T = np.array(extr.tvec) + uid = intr.id # 相机的唯一标识符 + R = np.transpose(qvec2rotmat(extr.qvec)) # 将四元数表示的旋转转换为旋转矩阵R + T = np.array(extr.tvec) # 外参中的平移向量 + + # 根据相机内参模型计算视场角(FoV) if intr.model=="SIMPLE_PINHOLE": + # 如果是简单针孔模型,只有一个焦距参数 focal_length_x = intr.params[0] - FovY = focal2fov(focal_length_x, height) - FovX = focal2fov(focal_length_x, width) + FovY = focal2fov(focal_length_x, height) # 计算垂直方向的视场角 + FovX = focal2fov(focal_length_x, width) # 计算水平方向的视场角 elif intr.model=="PINHOLE": + # 如果是针孔模型,有两个焦距参数 focal_length_x = intr.params[0] focal_length_y = intr.params[1] - FovY = focal2fov(focal_length_y, height) - FovX = focal2fov(focal_length_x, width) + FovY = focal2fov(focal_length_y, height) # 使用y方向的焦距计算垂直视场角 + FovX = focal2fov(focal_length_x, width) # 使用x方向的焦距计算水平视场角 + elif intr.model=="SIMPLE_RADIAL": + # 如果是针孔模型,有两个焦距参数 + focal_length_x = intr.params[0] + focal_length_y = intr.params[1] + FovY = focal2fov(focal_length_y, height) # 使用y方向的焦距计算垂直视场角 + FovX = focal2fov(focal_length_x, width) # 使用x方向的焦距计算水平视场角 else: + # 如果不是以上两种模型,抛出错误 assert False, "Colmap camera model not handled: only undistorted datasets (PINHOLE or SIMPLE_PINHOLE cameras) supported!" image_path = os.path.join(images_folder, os.path.basename(extr.name)) @@ -101,11 +116,16 @@ def readColmapCameras(cam_extrinsics, cam_intrinsics, images_folder): cam_info = CameraInfo(uid=uid, R=R, T=T, FovY=FovY, FovX=FovX, image=image, image_path=image_path, image_name=image_name, width=width, height=height) cam_infos.append(cam_info) + # 在读取完所有相机信息后换行 sys.stdout.write('\n') + + # 返回整理好的相机信息列表 return cam_infos def fetchPly(path): + # 读取.ply文件 plydata = PlyData.read(path) + # 其第一个属性,即vertex的信息为:x', 'y', 'z', 'nx', 'ny', 'nz', 3个'f_dc_x', 45个'f_rest_xx', 'opacity', 3个'scale_x', 4个'rot_x' vertices = plydata['vertex'] positions = np.vstack([vertices['x'], vertices['y'], vertices['z']]).T colors = np.vstack([vertices['red'], vertices['green'], vertices['blue']]).T / 255.0 @@ -129,6 +149,7 @@ def storePly(path, xyz, rgb): ply_data = PlyData([vertex_element]) ply_data.write(path) +# 尝试读取COLMAP处理结果中的二进制相机外参文件imags.bin 和 内参文件cameras.bin def readColmapSceneInfo(path, images, eval, llffhold=8): try: cameras_extrinsic_file = os.path.join(path, "sparse/0", "images.bin") @@ -136,39 +157,50 @@ def readColmapSceneInfo(path, images, eval, llffhold=8): cam_extrinsics = read_extrinsics_binary(cameras_extrinsic_file) cam_intrinsics = read_intrinsics_binary(cameras_intrinsic_file) except: + # 如果二进制文件读取失败,尝试读取文本格式的相机外参和内参文件 cameras_extrinsic_file = os.path.join(path, "sparse/0", "images.txt") cameras_intrinsic_file = os.path.join(path, "sparse/0", "cameras.txt") cam_extrinsics = read_extrinsics_text(cameras_extrinsic_file) cam_intrinsics = read_intrinsics_text(cameras_intrinsic_file) + # 定义存放图片的目录,如果未指定则默认为"images" reading_dir = "images" if images == None else images + + # 读取并处理相机参数,转换为内部使用的格式 cam_infos_unsorted = readColmapCameras(cam_extrinsics=cam_extrinsics, cam_intrinsics=cam_intrinsics, images_folder=os.path.join(path, reading_dir)) + # 根据图片名称对相机信息进行排序,以保证顺序一致性 cam_infos = sorted(cam_infos_unsorted.copy(), key = lambda x : x.image_name) + # 根据是否为评估模式(eval),将相机分为训练集和测试集 + # 如果为评估模式,根据llffhold参数(通常用于LLFF数据集)间隔选择测试相机 if eval: train_cam_infos = [c for idx, c in enumerate(cam_infos) if idx % llffhold != 0] test_cam_infos = [c for idx, c in enumerate(cam_infos) if idx % llffhold == 0] else: + # 如果不是评估模式,所有相机均为训练相机,测试相机列表为空 train_cam_infos = cam_infos test_cam_infos = [] + # 计算场景归一化参数,这是为了处理不同尺寸和位置的场景,使模型训练更稳定 nerf_normalization = getNerfppNorm(train_cam_infos) + # 尝试读取点云数据,优先从PLY文件读取,如果不存在,则尝试从BIN或TXT文件转换并保存为PLY格式 ply_path = os.path.join(path, "sparse/0/points3D.ply") bin_path = os.path.join(path, "sparse/0/points3D.bin") txt_path = os.path.join(path, "sparse/0/points3D.txt") if not os.path.exists(ply_path): print("Converting point3d.bin to .ply, will happen only the first time you open the scene.") try: - xyz, rgb, _ = read_points3D_binary(bin_path) + xyz, rgb, _ = read_points3D_binary(bin_path) # 从points3D.bin读取COLMAP产生的稀疏点云 except: xyz, rgb, _ = read_points3D_text(txt_path) - storePly(ply_path, xyz, rgb) + storePly(ply_path, xyz, rgb) # 转换成ply文件 try: pcd = fetchPly(ply_path) except: pcd = None + # 组装场景信息,包括点云、训练用相机、测试用相机、场景归一化参数和点云文件路径 scene_info = SceneInfo(point_cloud=pcd, train_cameras=train_cam_infos, test_cameras=test_cam_infos, diff --git a/scene/gaussian_model.py b/scene/gaussian_model.py index 00ffea6..3c8b592 100644 --- a/scene/gaussian_model.py +++ b/scene/gaussian_model.py @@ -24,38 +24,54 @@ from utils.general_utils import strip_symmetric, build_scaling_rotation class GaussianModel: def setup_functions(self): + """ + 定义和初始化一些用于处理3D高斯模型参数的函数 + """ + + # 定义构建3D高斯协方差矩阵的函数 def build_covariance_from_scaling_rotation(scaling, scaling_modifier, rotation): - L = build_scaling_rotation(scaling_modifier * scaling, rotation) - actual_covariance = L @ L.transpose(1, 2) - symm = strip_symmetric(actual_covariance) + L = build_scaling_rotation(scaling_modifier * scaling, rotation) # 从尺度、尺度的缩放、旋转得到L矩阵 + actual_covariance = L @ L.transpose(1, 2) # 计算实际的协方差矩阵 + symm = strip_symmetric(actual_covariance) # 提取对称部分 return symm - - self.scaling_activation = torch.exp - self.scaling_inverse_activation = torch.log - self.covariance_activation = build_covariance_from_scaling_rotation + # 初始化一些激活函数 + self.scaling_activation = torch.exp # 用exp函数,确保尺度参数非负 + self.scaling_inverse_activation = torch.log # 尺度的逆激活函数,用于梯度回传 - self.opacity_activation = torch.sigmoid - self.inverse_opacity_activation = inverse_sigmoid + self.covariance_activation = build_covariance_from_scaling_rotation # 协方差矩阵的激活函数 - self.rotation_activation = torch.nn.functional.normalize + self.opacity_activation = torch.sigmoid # 用sigmoid函数,确保不透明度在0到1之间 + self.inverse_opacity_activation = inverse_sigmoid # 不透明度的逆激活函数 + + self.rotation_activation = torch.nn.functional.normalize # 用于标准化旋转参数的函数 def __init__(self, sh_degree : int): - self.active_sh_degree = 0 - self.max_sh_degree = sh_degree - self._xyz = torch.empty(0) # 世界坐标 - self._features_dc = torch.empty(0) # diffuse color - self._features_rest = torch.empty(0) # spherical harmonic coefficients - self._scaling = torch.empty(0) # 3d scale - self._rotation = torch.empty(0) # rotation expressed in quaternions - self._opacity = torch.empty(0) # opacity - self.max_radii2D = torch.empty(0) - self.xyz_gradient_accum = torch.empty(0) - self.denom = torch.empty(0) - self.optimizer = None - self.percent_dense = 0 - self.spatial_lr_scale = 0 + """ + 初始化3D高斯模型的参数 + sh_degree: 球谐函数的最大阶数,用于控制颜色表示的复杂度 + """ + # 初始化球谐阶数和最大球谐阶数j + self.active_sh_degree = 0 # 当前激活的球谐阶数,初始为0 + self.max_sh_degree = sh_degree # 允许的最大球谐阶数 + + # 初始化3D高斯模型的各项参数 + self._xyz = torch.empty(0) # 3D高斯的中心位置(均值) + self._features_dc = torch.empty(0) # 第一个球谐系数,用于表示基础颜色 + self._features_rest = torch.empty(0) # 其余的球谐系数,用于表示颜色的细节和变化 + self._scaling = torch.empty(0) # 3D高斯的尺度参数,控制高斯的形状 + self._rotation = torch.empty(0) # 3D高斯的旋转参数(一系列四元数) + self._opacity = torch.empty(0) # 3D高斯的不透明度(sigmoid前的),控制可见性 + self.max_radii2D = torch.empty(0) # 在2D投影中,每个高斯的最大半径 + self.xyz_gradient_accum = torch.empty(0) # 累积3D高斯中心位置的梯度,当它太大的时候要对Gaussian进行分裂,小时代表under要复制 + self.denom = torch.empty(0) # 与累积梯度配合使用,表示统计了多少次累积梯度,算平均梯度时除掉这个(denom = denominator,分母) + self.optimizer = None # 优化器,用于调整上述参数以改进模型(论文中采用Adam,见附录B Algorithm 1的伪代码) + + self.percent_dense = 0 # 控制Gaussian密集程度的超参数 + self.spatial_lr_scale = 0 # 位置坐标的学习率要乘上这个,抵消在不同尺度下应用同一个学习率带来的问题 + + # 调用setup_functions来初始化一些处理函数 self.setup_functions() def capture(self): @@ -122,35 +138,71 @@ class GaussianModel: self.active_sh_degree += 1 def create_from_pcd(self, pcd : BasicPointCloud, spatial_lr_scale : float): - self.spatial_lr_scale = spatial_lr_scale - fused_point_cloud = torch.tensor(np.asarray(pcd.points)).float().cuda() - fused_color = RGB2SH(torch.tensor(np.asarray(pcd.colors)).float().cuda()) - features = torch.zeros((fused_color.shape[0], 3, (self.max_sh_degree + 1) ** 2)).float().cuda() - features[:, :3, 0 ] = fused_color - features[:, 3:, 1:] = 0.0 + """ + 从点云数据初始化模型参数 + :param pcd: 稀疏点云数据,包含点的位置和颜色 + :param spatial_lr_scale: 空间学习率缩放因子,影响 位置坐标参数的学习率 + """ + + # 根据scene.Scene.__init__ 以及 scene.dataset_readers.SceneInfo.nerf_normalization,即scene.dataset_readers.getNerfppNorm的代码, + # 这个值似乎是训练相机中离它们的坐标平均值(即中心)最远距离的1.1倍,根据命名推断应该与学习率有关,防止固定的学习率适配不同尺度的场景时出现问题。 + self.spatial_lr_scale = spatial_lr_scale + + # 将点云的位置和颜色数据从numpy数组转换为PyTorch张量,并传送到CUDA设备上 + fused_point_cloud = torch.tensor(np.asarray(pcd.points)).float().cuda() # 稀疏点云的3D坐标,大小为(P, 3) + fused_color = RGB2SH(torch.tensor(np.asarray(pcd.colors)).float().cuda()) # 球谐的直流分量,大小为(P, 3), + # RGB2SH(x) = (x - 0.5) / 0.28209479177387814看样子pcd.colors的原始范围应该是0到1。0.28209479177387814是1 / (2*sqrt(pi)),是直流分量Y(l=0,m=0)的值 + + # 初始化存储球谐系数的张量,RGB三通道球谐的所有系数,每个通道有(max_sh_degree + 1) ** 2个球谐系数 + features = torch.zeros((fused_color.shape[0], 3, (self.max_sh_degree + 1) ** 2)).float().cuda() # (P, 3, 16) + features[:, :3, 0 ] = fused_color # 将RGB转换后的球谐系数C0项的系数(直流分量)存入 + features[:, 3:, 1:] = 0.0 # 其余球谐系数初始化为0 + + # 打印初始点的数量 print("Number of points at initialisation : ", fused_point_cloud.shape[0]) - dist2 = torch.clamp_min(distCUDA2(torch.from_numpy(np.asarray(pcd.points)).float().cuda()), 0.0000001) - scales = torch.log(torch.sqrt(dist2))[...,None].repeat(1, 3) - rots = torch.zeros((fused_point_cloud.shape[0], 4), device="cuda") - rots[:, 0] = 1 + # 计算点云中每个点到其最近的k个点的平均距离的 平方,用于确定高斯的尺度参数scale,且scale(的平方)不能低于1e-7 + # distCUDA2由 submodules/simple-knn/simple_knn.cu 的 SimpleKNN::knn 函数实现,KNN意思是K-Nearest Neighbor,即求每一点最近的K个点 + dist2 = torch.clamp_min(distCUDA2(torch.from_numpy(np.asarray(pcd.points)).float().cuda()), 0.0000001) # (P,) - opacities = inverse_sigmoid(0.1 * torch.ones((fused_point_cloud.shape[0], 1), dtype=torch.float, device="cuda")) + # 因为scale的激活函数是exp,所以这里存的也不是真的scale,而是ln(scale)。 + # 因dist2其实是距离的平方,所以这里要开根号 + # repeat(1, 3) 标明三个方向上scale的初始值是相等的 + scales = torch.log(torch.sqrt(dist2))[...,None].repeat(1, 3) # (P, 3) - self._xyz = nn.Parameter(fused_point_cloud.requires_grad_(True)) - self._features_dc = nn.Parameter(features[:,:,0:1].transpose(1, 2).contiguous().requires_grad_(True)) - self._features_rest = nn.Parameter(features[:,:,1:].transpose(1, 2).contiguous().requires_grad_(True)) - self._scaling = nn.Parameter(scales.requires_grad_(True)) - self._rotation = nn.Parameter(rots.requires_grad_(True)) - self._opacity = nn.Parameter(opacities.requires_grad_(True)) - self.max_radii2D = torch.zeros((self.get_xyz.shape[0]), device="cuda") + # 初始化每个点的旋转参数为单位四元数(无旋转) + rots = torch.zeros((fused_point_cloud.shape[0], 4), device="cuda") # (P, 4) + rots[:, 0] = 1 # 四元数的实部为1,表示无旋转 + + # 初始化每个点的不透明度在sigmoid前的值为0.1,inverse_sigmoid是sigmoid的反函数,等于ln(x / (1 - x))。 + # 不透明度存储的时候要取其经历sigmoid前的值,inverse_sigmoid(0.1) = -2.197 + opacities = inverse_sigmoid(0.1 * torch.ones((fused_point_cloud.shape[0], 1), dtype=torch.float, device="cuda")) # (P, 1) + + # 将以上计算的参数设置为模型的可训练参数 + self._xyz = nn.Parameter(fused_point_cloud.requires_grad_(True)) # 高斯椭球体中心位置坐标,(N, 3) + self._features_dc = nn.Parameter(features[:,:,0:1].transpose(1, 2).contiguous().requires_grad_(True)) # RGB三个通道球谐系数的直流分量(C0项),(N, 3, 1) + self._features_rest = nn.Parameter(features[:,:,1:].transpose(1, 2).contiguous().requires_grad_(True)) # RGB三个通道球谐系数的高阶分量,(N, 3, (最大球谐阶数 + 1)² - 1) + self._scaling = nn.Parameter(scales.requires_grad_(True)) # 尺度(N, 3) + self._rotation = nn.Parameter(rots.requires_grad_(True)) # 旋转四元数(N, 4) + self._opacity = nn.Parameter(opacities.requires_grad_(True)) # 不透明度(经过sigmoid之前),(N, 1) + self.max_radii2D = torch.zeros((self.get_xyz.shape[0]), device="cuda") # 存储2D投影的最大半径,初始化为0,大小为(N,) def training_setup(self, training_args): + """ + 设置训练参数,包括初始化用于累积梯度的变量,配置优化器,以及创建学习率调度器 + :param training_args: 包含训练相关参数的对象 + """ + # 设置在训练过程中,用于密集化处理的3D高斯点的比例 + # 控制Gaussian的密度,在`densify_and_clone`中被使用 self.percent_dense = training_args.percent_dense - self.xyz_gradient_accum = torch.zeros((self.get_xyz.shape[0], 1), device="cuda") - self.denom = torch.zeros((self.get_xyz.shape[0], 1), device="cuda") + # 初始化用于累积3D高斯中心点位置梯度的张量,用于之后判断是否需要对3D高斯进行克隆或切分 + self.xyz_gradient_accum = torch.zeros((self.get_xyz.shape[0], 1), device="cuda") # 坐标的累积梯度 + + self.denom = torch.zeros((self.get_xyz.shape[0], 1), device="cuda") # 意义不明 + + # 配置各参数的优化器,包括指定参数、学习率和参数名称 l = [ {'params': [self._xyz], 'lr': training_args.position_lr_init * self.spatial_lr_scale, "name": "xyz"}, {'params': [self._features_dc], 'lr': training_args.feature_lr, "name": "f_dc"}, @@ -160,13 +212,17 @@ class GaussianModel: {'params': [self._rotation], 'lr': training_args.rotation_lr, "name": "rotation"} ] + # 创建优化器,这里使用Adam优化器 self.optimizer = torch.optim.Adam(l, lr=0.0, eps=1e-15) + + # 创建学习率调度器,用于对中心点位置的学习率进行调整 self.xyz_scheduler_args = get_expon_lr_func(lr_init=training_args.position_lr_init*self.spatial_lr_scale, lr_final=training_args.position_lr_final*self.spatial_lr_scale, lr_delay_mult=training_args.position_lr_delay_mult, max_steps=training_args.position_lr_max_steps) def update_learning_rate(self, iteration): + # 更新Gaussian坐标的学习率 ''' Learning rate scheduling per step ''' for param_group in self.optimizer.param_groups: if param_group["name"] == "xyz": @@ -174,17 +230,19 @@ class GaussianModel: param_group['lr'] = lr return lr + # 模型被保存到了/point_cloud/iteration_xxx/point_cloud.ply文件中,使用PlyData.read()读取,其第一个属性,即vertex的信息为:x', 'y', 'z', 'nx', 'ny', 'nz', 3个'f_dc_x', 45个'f_rest_xx', 'opacity', 3个'scale_x', 4个'rot_x' def construct_list_of_attributes(self): - l = ['x', 'y', 'z', 'nx', 'ny', 'nz'] + # 构建ply文件的键列表 + l = ['x', 'y', 'z', 'nx', 'ny', 'nz'] # 不知道nx,ny,nz的用处 # All channels except the 3 DC - for i in range(self._features_dc.shape[1]*self._features_dc.shape[2]): + for i in range(self._features_dc.shape[1] * self._features_dc.shape[2]): # self._features_dc: (N, 3, 1) l.append('f_dc_{}'.format(i)) - for i in range(self._features_rest.shape[1]*self._features_rest.shape[2]): + for i in range(self._features_rest.shape[1] * self._features_rest.shape[2]): # self._features_rest: (N, 3, (最大球谐阶数 + 1)² - 1) l.append('f_rest_{}'.format(i)) l.append('opacity') - for i in range(self._scaling.shape[1]): + for i in range(self._scaling.shape[1]): # shape[1]: 3 l.append('scale_{}'.format(i)) - for i in range(self._rotation.shape[1]): + for i in range(self._rotation.shape[1]): # shape[1]: 4 l.append('rot_{}'.format(i)) return l @@ -192,7 +250,7 @@ class GaussianModel: mkdir_p(os.path.dirname(path)) xyz = self._xyz.detach().cpu().numpy() - normals = np.zeros_like(xyz) + normals = np.zeros_like(xyz) # # nx, ny, nz;全是0,不知何用 f_dc = self._features_dc.detach().transpose(1, 2).flatten(start_dim=1).contiguous().cpu().numpy() f_rest = self._features_rest.detach().transpose(1, 2).flatten(start_dim=1).contiguous().cpu().numpy() opacities = self._opacity.detach().cpu().numpy() @@ -202,17 +260,21 @@ class GaussianModel: dtype_full = [(attribute, 'f4') for attribute in self.construct_list_of_attributes()] elements = np.empty(xyz.shape[0], dtype=dtype_full) + # 所有要保存的值合并成一个大数组 attributes = np.concatenate((xyz, normals, f_dc, f_rest, opacities, scale, rotation), axis=1) elements[:] = list(map(tuple, attributes)) el = PlyElement.describe(elements, 'vertex') PlyData([el]).write(path) def reset_opacity(self): - opacities_new = inverse_sigmoid(torch.min(self.get_opacity, torch.ones_like(self.get_opacity)*0.01)) - optimizable_tensors = self.replace_tensor_to_optimizer(opacities_new, "opacity") + # get_opacity返回了经过exp的不透明度,是真的不透明度 + # 这句话让所有不透明度都不能超过0.01 + opacities_new = inverse_sigmoid(torch.min(self.get_opacity, torch.ones_like(self.get_opacity) * 0.01)) + optimizable_tensors = self.replace_tensor_to_optimizer(opacities_new, "opacity") # 更新优化器中的不透明度 self._opacity = optimizable_tensors["opacity"] def load_ply(self, path): + # 读取ply文件并把数据转换成torch.nn.Parameter等待优化 plydata = PlyData.read(path) xyz = np.stack((np.asarray(plydata.elements[0]["x"]), @@ -256,12 +318,14 @@ class GaussianModel: self.active_sh_degree = self.max_sh_degree def replace_tensor_to_optimizer(self, tensor, name): + # 看样子是把优化器保存的某个名为`name`的参数的值强行替换为`tensor` + # 这里面需要注意的是修改Adam优化器的状态变量:动量(momentum)和平方动量(second-order momentum) optimizable_tensors = {} for group in self.optimizer.param_groups: if group["name"] == name: stored_state = self.optimizer.state.get(group['params'][0], None) - stored_state["exp_avg"] = torch.zeros_like(tensor) - stored_state["exp_avg_sq"] = torch.zeros_like(tensor) + stored_state["exp_avg"] = torch.zeros_like(tensor) # 把动量清零 + stored_state["exp_avg_sq"] = torch.zeros_like(tensor) # 把平方动量清零 del self.optimizer.state[group['params'][0]] group["params"][0] = nn.Parameter(tensor.requires_grad_(True)) @@ -271,6 +335,7 @@ class GaussianModel: return optimizable_tensors def _prune_optimizer(self, mask): + # 根据`mask`裁剪一部分参数及其动量和二阶动量 optimizable_tensors = {} for group in self.optimizer.param_groups: stored_state = self.optimizer.state.get(group['params'][0], None) @@ -289,9 +354,11 @@ class GaussianModel: return optimizable_tensors def prune_points(self, mask): + # 删除Gaussian并移除对应的所有属性 valid_points_mask = ~mask optimizable_tensors = self._prune_optimizer(valid_points_mask) + # 重置各个参数 self._xyz = optimizable_tensors["xyz"] self._features_dc = optimizable_tensors["f_dc"] self._features_rest = optimizable_tensors["f_rest"] @@ -305,6 +372,7 @@ class GaussianModel: self.max_radii2D = self.max_radii2D[valid_points_mask] def cat_tensors_to_optimizer(self, tensors_dict): + # 把新的张量字典添加到优化器 optimizable_tensors = {} for group in self.optimizer.param_groups: assert len(group["params"]) == 1 @@ -327,6 +395,7 @@ class GaussianModel: return optimizable_tensors def densification_postfix(self, new_xyz, new_features_dc, new_features_rest, new_opacities, new_scaling, new_rotation): + # 新增Gaussian,把新属性添加到优化器中 d = {"xyz": new_xyz, "f_dc": new_features_dc, "f_rest": new_features_rest, @@ -355,10 +424,19 @@ class GaussianModel: selected_pts_mask = torch.logical_and(selected_pts_mask, torch.max(self.get_scaling, dim=1).values > self.percent_dense*scene_extent) + ''' + 被分裂的Gaussians满足两个条件: + 1. (平均)梯度过大; + 2. 在某个方向的最大缩放大于一个阈值。 + 参照论文5.2节“On the other hand...”一段,大Gaussian被分裂成两个小Gaussians, + 其放缩被除以φ=1.6,且位置是以原先的大Gaussian作为概率密度函数进行采样的。 + ''' + stds = self.get_scaling[selected_pts_mask].repeat(N,1) means =torch.zeros((stds.size(0), 3),device="cuda") samples = torch.normal(mean=means, std=stds) rots = build_rotation(self._rotation[selected_pts_mask]).repeat(N,1,1) + # 算出随机采样出来的新坐标。bmm: batch matrix-matrix product new_xyz = torch.bmm(rots, samples.unsqueeze(-1)).squeeze(-1) + self.get_xyz[selected_pts_mask].repeat(N, 1) new_scaling = self.scaling_inverse_activation(self.get_scaling[selected_pts_mask].repeat(N,1) / (0.8*N)) new_rotation = self._rotation[selected_pts_mask].repeat(N,1) @@ -376,6 +454,7 @@ class GaussianModel: selected_pts_mask = torch.where(torch.norm(grads, dim=-1) >= grad_threshold, True, False) selected_pts_mask = torch.logical_and(selected_pts_mask, torch.max(self.get_scaling, dim=1).values <= self.percent_dense*scene_extent) + # 提取出大于阈值`grad_threshold`且缩放参数较小(小于self.percent_dense * scene_extent)的Gaussians,在下面进行克隆 new_xyz = self._xyz[selected_pts_mask] new_features_dc = self._features_dc[selected_pts_mask] @@ -387,21 +466,26 @@ class GaussianModel: self.densification_postfix(new_xyz, new_features_dc, new_features_rest, new_opacities, new_scaling, new_rotation) def densify_and_prune(self, max_grad, min_opacity, extent, max_screen_size): - grads = self.xyz_gradient_accum / self.denom + grads = self.xyz_gradient_accum / self.denom # 计算平均梯度 grads[grads.isnan()] = 0.0 - self.densify_and_clone(grads, max_grad, extent) - self.densify_and_split(grads, max_grad, extent) + self.densify_and_clone(grads, max_grad, extent) # 通过克隆增加密度 + self.densify_and_split(grads, max_grad, extent) # 通过分裂增加密度 + # 接下来移除一些Gaussians,它们满足下列要求中的一个: + # 1. 接近透明(不透明度小于min_opacity) + # 2. 在某个相机视野里出现过的最大2D半径大于屏幕(像平面)大小 + # 3. 在某个方向的最大缩放大于0.1 * extent(也就是说很长的长条形也是会被移除的) prune_mask = (self.get_opacity < min_opacity).squeeze() if max_screen_size: - big_points_vs = self.max_radii2D > max_screen_size + big_points_vs = self.max_radii2D > max_screen_size # vs = view space? big_points_ws = self.get_scaling.max(dim=1).values > 0.1 * extent - prune_mask = torch.logical_or(torch.logical_or(prune_mask, big_points_vs), big_points_ws) + prune_mask = torch.logical_or(torch.logical_or(prune_mask, big_points_vs), big_points_ws) # ws = world space? self.prune_points(prune_mask) torch.cuda.empty_cache() def add_densification_stats(self, viewspace_point_tensor, update_filter): + # 统计坐标的累积梯度和均值的分母(即迭代步数?) self.xyz_gradient_accum[update_filter] += torch.norm(viewspace_point_tensor.grad[update_filter,:2], dim=-1, keepdim=True) self.denom[update_filter] += 1 \ No newline at end of file diff --git a/submodules/diff-gaussian-rasterization b/submodules/diff-gaussian-rasterization index 59f5f77..8ead777 160000 --- a/submodules/diff-gaussian-rasterization +++ b/submodules/diff-gaussian-rasterization @@ -1 +1 @@ -Subproject commit 59f5f77e3ddbac3ed9db93ec2cfe99ed6c5d121d +Subproject commit 8ead777020a16604d8b7a1d0bfac2dbb8df962a9 diff --git a/train.py b/train.py index 5d819b3..b9d34e6 100644 --- a/train.py +++ b/train.py @@ -10,8 +10,10 @@ # import os +import numpy as np import torch from random import randint +from PIL import Image from utils.loss_utils import l1_loss, ssim from gaussian_renderer import render, network_gui import sys @@ -31,24 +33,31 @@ except ImportError: def training(dataset, opt, pipe, testing_iterations, saving_iterations, checkpoint_iterations, checkpoint, debug_from): first_iter = 0 tb_writer = prepare_output_and_logger(dataset) - gaussians = GaussianModel(dataset.sh_degree) - scene = Scene(dataset, gaussians) - gaussians.training_setup(opt) + + gaussians = GaussianModel(dataset.sh_degree) # 创建初始化高斯模型,用于表示场景中的每个点的3D高斯分布 + scene = Scene(dataset, gaussians) # 创建初始3D场景对象,加载数据集和对应的相机参数 + gaussians.training_setup(opt) # 为高斯模型参数设置优化器和学习率调度器 + + # 如果提供了checkpoint,则从checkpoint加载模型参数并恢复训练进度 if checkpoint: (model_params, first_iter) = torch.load(checkpoint) gaussians.restore(model_params, opt) + # 设置背景颜色,白色或黑色取决于数据集要求 bg_color = [1, 1, 1] if dataset.white_background else [0, 0, 0] background = torch.tensor(bg_color, dtype=torch.float32, device="cuda") + # 创建CUDA事件用于计时 iter_start = torch.cuda.Event(enable_timing = True) iter_end = torch.cuda.Event(enable_timing = True) viewpoint_stack = None ema_loss_for_log = 0.0 + + # 使用tqdm库创建进度条,追踪训练进度 progress_bar = tqdm(range(first_iter, opt.iterations), desc="Training progress") first_iter += 1 - for iteration in range(first_iter, opt.iterations + 1): + for iteration in range(first_iter, opt.iterations + 1): if network_gui.conn == None: network_gui.try_connect() while network_gui.conn != None: @@ -64,25 +73,28 @@ def training(dataset, opt, pipe, testing_iterations, saving_iterations, checkpoi except Exception as e: network_gui.conn = None - iter_start.record() + iter_start.record() # 记录迭代开始时间 - gaussians.update_learning_rate(iteration) + gaussians.update_learning_rate(iteration) # 根据当前迭代次数更新学习率 - # Every 1000 its we increase the levels of SH up to a maximum degree + # 每1000次迭代,提升球谐函数的次数以改进模型复杂度,Every 1000 its we increase the levels of SH up to a maximum degree if iteration % 1000 == 0: gaussians.oneupSHdegree() - # Pick a random Camera + # 随机选择一个训练用的相机视角,Pick a random Camera if not viewpoint_stack: viewpoint_stack = scene.getTrainCameras().copy() viewpoint_cam = viewpoint_stack.pop(randint(0, len(viewpoint_stack)-1)) - # Render + # 如果达到调试起始点,启用调试模式 if (iteration - 1) == debug_from: pipe.debug = True + # Render,根据相机参数使用可微光栅化器渲染图像 + # 根据设置决定是否使用随机背景颜色 bg = torch.rand((3), device="cuda") if opt.random_background else background + # 渲染当前视角的图像 render_pkg = render(viewpoint_cam, gaussians, pipe, bg) image, viewspace_point_tensor, visibility_filter, radii = render_pkg["render"], render_pkg["viewspace_points"], render_pkg["visibility_filter"], render_pkg["radii"] @@ -92,10 +104,10 @@ def training(dataset, opt, pipe, testing_iterations, saving_iterations, checkpoi loss = (1.0 - opt.lambda_dssim) * Ll1 + opt.lambda_dssim * (1.0 - ssim(image, gt_image)) loss.backward() - iter_end.record() + iter_end.record() # 记录迭代结束时间 with torch.no_grad(): - # Progress bar + # 更新进度条和损失显示,Progress bar ema_loss_for_log = 0.4 * loss.item() + 0.6 * ema_loss_for_log if iteration % 10 == 0: progress_bar.set_postfix({"Loss": f"{ema_loss_for_log:.{7}f}"}) @@ -103,13 +115,13 @@ def training(dataset, opt, pipe, testing_iterations, saving_iterations, checkpoi if iteration == opt.iterations: progress_bar.close() - # Log and save + # 定期记录训练数据并保存模型,Log and save training_report(tb_writer, iteration, Ll1, loss, l1_loss, iter_start.elapsed_time(iter_end), testing_iterations, scene, render, (pipe, background)) if (iteration in saving_iterations): print("\n[ITER {}] Saving Gaussians".format(iteration)) scene.save(iteration) - # Densification + # 在指定迭代区间内,对3D高斯模型进行增密和修剪,Densification if iteration < opt.densify_until_iter: # Keep track of max radii in image-space for pruning gaussians.max_radii2D[visibility_filter] = torch.max(gaussians.max_radii2D[visibility_filter], radii[visibility_filter]) @@ -122,11 +134,12 @@ def training(dataset, opt, pipe, testing_iterations, saving_iterations, checkpoi if iteration % opt.opacity_reset_interval == 0 or (dataset.white_background and iteration == opt.densify_from_iter): gaussians.reset_opacity() - # Optimizer step + # 执行优化器的一步,并准备下一次迭代,Optimizer step if iteration < opt.iterations: gaussians.optimizer.step() gaussians.optimizer.zero_grad(set_to_none = True) + # 定期保存checkpoint if (iteration in checkpoint_iterations): print("\n[ITER {}] Saving Checkpoint".format(iteration)) torch.save((gaussians.capture(), iteration), scene.model_path + "/chkpnt" + str(iteration) + ".pth") @@ -193,9 +206,11 @@ def training_report(tb_writer, iteration, Ll1, loss, l1_loss, elapsed, testing_i if __name__ == "__main__": # Set up command line argument parser parser = ArgumentParser(description="Training script parameters") - lp = ModelParams(parser) - op = OptimizationParams(parser) - pp = PipelineParams(parser) + + lp = ModelParams(parser) # 模型 相关参数 + op = OptimizationParams(parser) # 优化 相关参数 + pp = PipelineParams(parser) # 渲染 相关参数 + parser.add_argument('--ip', type=str, default="127.0.0.1") parser.add_argument('--port', type=int, default=6009) parser.add_argument('--debug_from', type=int, default=-1) diff --git a/utils/general_utils.py b/utils/general_utils.py index 541c082..5bdfa4c 100644 --- a/utils/general_utils.py +++ b/utils/general_utils.py @@ -43,11 +43,23 @@ def get_expon_lr_func( :param max_steps: int, the number of steps during optimization. :return HoF which takes step as input """ + """ + 创建一个学习率调度函数,该函数根据训练进度动态调整学习率 + + :param lr_init: 初始学习率 + :param lr_final: 最终学习率 + :param lr_delay_steps: 学习率延迟步数,在这些步数内学习率将被降低 + :param lr_delay_mult: 学习率延迟乘数,用于计算初始延迟学习率 + :param max_steps: 最大步数,用于规范化训练进度 + :return: 一个函数,根据当前步数返回调整后的学习率 + """ def helper(step): + # 如果步数小于0或学习率为0,直接返回0,表示不进行优化 if step < 0 or (lr_init == 0.0 and lr_final == 0.0): # Disable this parameter return 0.0 + # 如果设置了学习率延迟步数,计算延迟调整后的学习率 if lr_delay_steps > 0: # A kind of reverse cosine decay. delay_rate = lr_delay_mult + (1 - lr_delay_mult) * np.sin( @@ -55,15 +67,23 @@ def get_expon_lr_func( ) else: delay_rate = 1.0 + # 根据步数计算学习率的对数线性插值,实现从初始学习率到最终学习率的平滑过渡 t = np.clip(step / max_steps, 0, 1) log_lerp = np.exp(np.log(lr_init) * (1 - t) + np.log(lr_final) * t) + # 返回调整后的学习率 return delay_rate * log_lerp return helper def strip_lowerdiag(L): + """ + 从协方差矩阵中提取六个独立参数 + :param L: 协方差矩阵 + :return: 六个独立参数组成的张量 + """ uncertainty = torch.zeros((L.shape[0], 6), dtype=torch.float, device="cuda") + # 提取协方差矩阵的独立元素 uncertainty[:, 0] = L[:, 0, 0] uncertainty[:, 1] = L[:, 0, 1] uncertainty[:, 2] = L[:, 0, 2] @@ -73,9 +93,17 @@ def strip_lowerdiag(L): return uncertainty def strip_symmetric(sym): + """ + 提取协方差矩阵的对称部分 + :param sym: 协方差矩阵 + :return: 对称部分 + """ return strip_lowerdiag(sym) def build_rotation(r): + ''' + 从旋转四元数 -> 单位化 -> 3x3的旋转矩阵 + ''' norm = torch.sqrt(r[:,0]*r[:,0] + r[:,1]*r[:,1] + r[:,2]*r[:,2] + r[:,3]*r[:,3]) q = r / norm[:, None] @@ -99,14 +127,22 @@ def build_rotation(r): return R def build_scaling_rotation(s, r): - L = torch.zeros((s.shape[0], 3, 3), dtype=torch.float, device="cuda") - R = build_rotation(r) + """ + 构建3D高斯模型的尺度-旋转矩阵 + :param s: 尺度参数 + :param r: 旋转参数 + :return: 尺度-旋转矩阵 + """ + L = torch.zeros((s.shape[0], 3, 3), dtype=torch.float, device="cuda") # 初始化尺度矩阵 + R = build_rotation(r) # 四元数 -> 旋转矩阵 + + # 设置尺度矩阵的对角线元素 L[:,0,0] = s[:,0] L[:,1,1] = s[:,1] L[:,2,2] = s[:,2] - L = R @ L + L = R @ L # 应用旋转 return L def safe_state(silent): diff --git a/utils/sh_utils.py b/utils/sh_utils.py index bbca7d1..ee17f8e 100644 --- a/utils/sh_utils.py +++ b/utils/sh_utils.py @@ -112,6 +112,11 @@ def eval_sh(deg, sh, dirs): return result def RGB2SH(rgb): + """ + 将RGB颜色值转换为球谐系数C0项的系数 + :param rgb: RGB颜色值 + :return: 转换后的球谐系数C0项的系数 + """ return (rgb - 0.5) / C0 def SH2RGB(sh):