简介:这份压缩包面向希望入门或复现三维点云语义分割的开发者与课程项目学习者,围绕PointNet在虚拟KITTI3D数据集上的训练与评估展开,覆盖从数据准备、模型搭建到可视化检查的完整流程。包内共54个文件,以20个Python脚本为主,承担网络结构、训练、批量推理、IoU精度评估与点云可视化等任务,并辅以Jupyter Notebook主训练入口、预训练checkpoint、说明文档以及少量日志和配置文件;压缩后约21.73MB,体积适中,便于在本地快速展开实验。已有942人学习下载,说明该方向具备一定关注度。具体价值在于可对照XYZRGBL七维点云数据理解语义标签组织方式,借助现成脚本完成训练、精度统计与Vispy可视化,并参考PointNet及PointNet++相关思路排查数据预处理、特征学习与评估环节的问题,适合作为点云分割课程设计、算法复现和二次开发的起点。
1. 先别急着搭网络:VKITTI3D 到点云这一步决定了后面 80% 的上限
很多人拿到这个 PointNet 语义分割的资源,第一反应是打开模型文件看网络结构。我第一遍也这么干,结果卡了整整三天,卡的地方跟网络一点关系都没有——是深度图反投影出来的点云歪了。VKITTI3D 是一套合成驾驶场景数据,每个 sequence 都有 RGB、16 位深度图、语义与实例分割、法向图、前后向光流和逐帧相机位姿,还按 clone、morning、overcast、rain、sunset、fog 六种天气光照各渲染一遍。这份资源做的事情很明确:把这些深度图按相机内参反投影成点云,逐点打上 13 类语义标签,喂给 PointNet 的分割分支做逐点分类。它适合已经跑通过分类网络、想摸点云分割完整链路的人;如果你只想调参刷点,那这套东西的价值全在数据管线里,网络本身只有几十行。
2. 深度图反投影与 13 类标签对齐:VKITTI3D 转点云的四步
VKITTI3D 的目录结构在不同分发版本里略有差异,但骨架是稳定的:一个 sequence 目录下按 frame 编号组织,每一帧有 rgb、depth、semantic segmentation、instance segmentation、法向图和位姿文件。合成数据的最大好处是没有真实激光雷达那种稀疏和噪声,坏处是它对"你以为你懂了"这件事毫不留情——深度值单位、内参坐标系、类别调色板,任何一处差一点,点云看起来都对,训练 loss 也照降,但模型学到的东西是错的。
我一般的顺序是:先只处理一帧,把点云用 matplotlib 或 open3d 画出来,肉眼确认车身、路面、路边护栏的位置和尺度都合理,再批量跑全量。批量跑的时候把中间结果落成 npy,后面调网络时就不用反复解码 PNG。
2.1 深度图到点云:反投影公式与坐标轴对齐
针孔模型的反投影就三行公式:Z = raw / DEPTH_SCALE,X = (u - cx) * Z / fx,Y = (v - cy) * Z / fy。真正容易翻车的是DEPTH_SCALE这个常数和无效值处理。16 位 PNG 里存的是整数,不同版本的换算系数可能是除以 100 得米,也可能是除以 10000,必须以数据集随包的说明或转换脚本为准,不要凭印象写。
import numpy as np def depth_to_points(depth_raw, K, depth_scale=100.0): """ depth_raw : (H, W) uint16 深度图 K : dict,含 fx / fy / cx / cy,必须是当前分辨率下的内参 depth_scale: 原始整数 -> 米 的除数,务必按数据集说明核对 返回 : (N, 3) float32,相机坐标系,x 右 / y 下 / z 前 """ H, W = depth_raw.shape z = depth_raw.astype(np.float32) / depth_scale # 0 表示无回波,65535 是常见填充值,两头都要剔掉 mask = (depth_raw > 0) & (depth_raw < 65535) v, u = np.nonzero(mask) zz = z[mask] xx = (u.astype(np.float32) - K["cx"]) * zz / K["fx"] yy = (v.astype(np.float32) - K["cy"]) * zz / K["fy"] return np.stack([xx, yy, zz], axis=1), (v, u)这段代码有两个设计取舍。第一,返回(v, u)索引是为了后面按同一批像素去取语义标签和 RGB,保证点和标签严格一一对应,不要做完反投影再回头去找像素,那样很容易错位。第二,深度用 float32 而不是 float64,一帧十几万点,全量跑下来内存差一倍。
相机坐标系是 x 右、y 下、z 前,跟激光雷达习惯的 x 前、y 左、z 上不一样。如果后面要接 KITTI 风格的评估脚本或者做 BEV 可视化,需要在落盘前转一次:
# 相机系 (右-下-前) -> 车体系 (前-左-上) CAM2BODY = np.array([[0, 0, 1], [-1, 0, 0], [0, -1, 0]], dtype=np.float32) points_body = points_cam @ CAM2BODY.T这一步不是必须的,但一定要在项目开始就定死,别一半数据是相机系、一半是车体系,后面查起来非常痛苦。合成序列里相机有俯仰和随车运动,逐帧位姿文件给的是 3×4 矩阵,点云 @ R.T + t就能转到世界坐标;做单帧语义分割其实用相机系就够了,但做多帧累积或者跨帧一致性检查时必须转。
2.2 语义标签对齐:调色板、容差与类别编号
VKITTI3D 的语义分割 PNG 是用颜色编码类别的,13 类分别是 Terrain、Sky、Tree、Vegetation、Building、Road、GuardRail、TrafficSign、TrafficLight、Pole、Misc、Truck、Car。颜色到类别 id 的对应关系一般随包给了一份对照文件,有的版本叫 label_colors 之类的名字,有的直接写在 README 的表格里。不管哪种,都要把这份对照读成一张class_id -> (R, G, B)的表,而不是散落在代码里的 if-else。
这里有个非常典型的坑:PNG 在渲染边缘做了抗锯齿或压缩,护栏、电线杆这类细长物体的边缘像素颜色是混合出来的,精确等值匹配会丢掉大量边缘点。我一般用容差匹配,并且统计匹配不上的像素占比,超过千分之五就要回头查颜色表。
def rgb_to_label(sem_rgb, palette, tol=12, ignore_index=255): """ sem_rgb : (H, W, 3) uint8 palette : (num_class, 3) uint8,第 i 行是类别 i 的颜色 tol : L1 颜色距离容差,合成数据 8~16 比较稳 """ flat = sem_rgb.reshape(-1, 3).astype(np.int16) colors, inverse = np.unique(flat, axis=0, return_inverse=True) label = np.full(len(colors), ignore_index, dtype=np.uint8) for cls_id, color in enumerate(palette.astype(np.int16)): dist = np.abs(colors - color).sum(axis=1) hit = (dist <= tol) & (label == ignore_index) label[hit] = cls_id unmatched = (label == ignore_index).sum() if unmatched: print(f"[warn] {unmatched} 个唯一颜色未匹配,占 {unmatched/len(colors):.4%}") return label[inverse].reshape(sem_rgb.shape[:2])参数上tol不建议开太大,开到 30 以上容易把 Vegetation 和 Tree 混掉,这两个类在合成数据里颜色本来就接近;如果发现细类丢点严重,优先查是不是图像被做过缩放或者 JPEG 中转,而不是无脑调容差。
另外,Sky 和 Terrain 这两类在点云里一个是无穷远、一个是贴地大平面,量都极大。训练时是否保留要看你关注什么:做车载感知,Sky 基本没用,可以在建标签时直接置为 ignore_index,让损失函数跳过。
2.3 落盘格式与分块采样策略
一帧 1242×375 的深度图,有效点常在一二十万量级,不能整帧直接塞给 PointNet。落盘我一般存成单个 npz,包含points (N,3) float32、colors (N,3) uint8、labels (N,) uint8和frame_id,一帧压缩后几 MB,几万帧的量级用普通硬盘也扛得住。存 pcd 的好处是能直接用 CloudCompare 打开看,坏处是读写慢、没法定制字段,调试阶段可以两份都出。
采样是决定上限的第二个关键点。PointNet 的分割分支对点数不敏感,但对空间分布很敏感。两种常见做法:一是全场景均匀随机采 N 点,二是按 XY 平面切网格、每格内随机采若干点。前者实现简单,但远处小目标(TrafficSign、TrafficLight)几乎必然被采没;后者能保住近处的密度分布,代价是远处格子点太少。
def block_sample(points, labels, colors, block=1024, grid=0.8, rng=None): """按 XY 网格分块采样,每块内随机取点,块间不重叠""" rng = rng or np.random.default_rng(0) xy = points[:, :2] key = np.floor(xy / grid).astype(np.int32) _, inv = np.unique(key, axis=0, return_inverse=True) picked = [] for g in range(inv.max() + 1): idx = np.nonzero(inv == g)[0] take = min(block, len(idx)) picked.append(rng.choice(idx, take, replace=False)) sel = np.concatenate(picked) return points[sel], labels[sel], colors[sel]grid=0.8米是我在城区场景里比较顺手的值,对应一个块大约覆盖 0.8×0.8 米;block=1024是 PointNet 分割分支的常规输入规模,显存 8G 的卡跑 batch 32 没问题。如果场景空旷、单块点数不足,要么把 grid 调大,要么允许跨块补齐,但补齐时别把相邻块的标签搞混。
3. PointNet 分割头拆解:1024 维全局特征怎么回流到每个点
PointNet 的分割分支跟分类分支共用前半段,区别在于分类只取 max pool 出来的全局特征,分割要把全局特征复制 N 份、跟每个点的局部特征拼接,再逐点过 MLP 出类别。理解这一点,你就明白为什么它对"全局上下文"的建模其实很弱——全局特征只有一个向量,所有点共享,模型能学到的只是"这帧场景大概是城区/高速"这种粗粒度先验,真正的逐点判别还是靠局部特征。
3.1 网络结构与输入通道的选择
标准结构是:输入变换(3×3 STN,可选)→ 共享 MLP(64,128,1024) → 逐点 max pool 得全局特征 → 拼接每点的 64 维中间特征与 1024 维全局特征 → MLP(512,256,128) → MLP(128, num_class)。中间的 feature transform(64×64)在语义分割上收益不明显,我一般关掉,省显存也省训练时间。
输入通道是第一个要定的事。只用 XYZ 是 3 通道,加上 RGB 是 6 通道,再加法向是 9 通道。VKITTI3D 是合成数据,RGB 干净、法向图也给了,加进去通常是正收益,但要注意:训练时用 RGB、推理时如果拿不到同样质量的颜色,会掉点。我自己的习惯是主实验用 XYZ+RGB,消融时对比纯 XYZ。
import torch import torch.nn as nn class PointNetSeg(nn.Module): def __init__(self, in_ch=6, num_class=13, dropout=0.3): super().__init__() self.mlp1 = nn.Sequential( nn.Conv1d(in_ch, 64, 1), nn.BatchNorm1d(64), nn.ReLU(), nn.Conv1d(64, 128, 1), nn.BatchNorm1d(128), nn.ReLU(), nn.Conv1d(128, 1024, 1), nn.BatchNorm1d(1024), ) self.mlp2 = nn.Sequential( nn.Conv1d(1024 + 64, 512, 1), nn.BatchNorm1d(512), nn.ReLU(), nn.Conv1d(512, 256, 1), nn.BatchNorm1d(256), nn.ReLU(), nn.Conv1d(256, 128, 1), nn.BatchNorm1d(128), nn.ReLU(), nn.Dropout(dropout), nn.Conv1d(128, num_class, 1), ) def forward(self, x): # x: (B, C, N) local = self.mlp1(x) # (B, 1024, N) glob = torch.max(local, dim=2, keepdim=True)[0] # (B, 1024, 1) feat = torch.cat([local[:, :64], glob.repeat(1, 1, x.size(2))], dim=1) return self.mlp2(feat) # (B, num_class, N)几个参数值得说清楚。in_ch必须跟数据管线输出的通道严格一致,改了这个数字却忘了改数据侧,报的错会非常隐晦;dropout=0.3放在最后一层 128 维之后,是 PointNet 原论文的做法,合成数据上如果过拟合不明显可以降到 0.2;所有 Conv1d 的 kernel 都是 1,本质上是对每个点独立做全连接,所以这个网络对点的输入顺序完全不敏感,这也是为什么打乱点序不影响结果。
3.2 损失函数与类别权重:别让路面吃掉全部梯度
VKITTI3D 的类别分布极度不均,Road、Terrain、Building、Vegetation 加起来可能占八成以上,TrafficSign、TrafficLight、Pole、GuardRail 这些加起来不到百分之几。直接用交叉熵,模型很快学会"全部预测成 Road",loss 看着还行,mIoU 惨不忍睹。
常见做法是给每个类一个反频率权重,PointNet 官方语义分割脚本里的公式是w_c = 1 / ln(1.02 + freq_c),其中 freq 是该类在训练集里的出现频率。这个公式比简单的倒数温和,不会把极稀有类放大到梯度爆炸。权重一定要在训练集上统计、存成文件,验证和测试复用同一份,别每次重新算。
import numpy as np def class_weights(label_hist, ignore_index=None): freq = label_hist.astype(np.float64) if ignore_index is not None: freq[ignore_index] = 0 freq = freq / freq.sum() w = 1.0 / np.log(1.02 + freq) w[freq == 0] = 0.0 # 训练集里没出现的类,权重置零 return torch.tensor(w, dtype=torch.float32)另外要把 ignore_index 用起来。前面把 Sky 置成 255、以及无深度像素对应的点,都应该在损失里跳过,否则模型会花大量容量去拟合这些本来就无意义的位置。
3.3 评估脚本:混淆矩阵比 per-batch 平均靠谱
IoU 一定要用全局混淆矩阵算,把所有 batch 的预测和标签累加成一个num_class × num_class的矩阵,最后一次性算 IoU 和 mIoU。见过太多实现是每个 batch 算一次 IoU 再取平均,小 batch 里某些类根本没出现,结果会系统性偏高。
def update_confusion(conf, pred, gt, num_class): # pred / gt: (N,) int64,gt 里的 ignore 已被过滤 idx = gt * num_class + pred conf += torch.bincount(idx, minlength=num_class ** 2).reshape(num_class, num_class) return conf def miou_from_confusion(conf): conf = conf.float() tp = conf.diag() fp = conf.sum(0) - tp fn = conf.sum(1) - tp iou = tp / (tp + fp + fn + 1e-9) valid = (tp + fn) > 0 # 测试集里没出现的类不参与 mIoU return iou, iou[valid].mean()valid这个掩码很重要。测试集如果某个类一个点都没有,它的 IoU 是 0/0,直接算进去会把 mIoU 拉低,属于假信号。评估时同时打印每类 IoU 和点数,比只看一个总分有用得多。
4. 训练与整帧推理:从 1024 点块到一帧十几万点的拼接
训练阶段按块走,推理阶段必须回到整帧,因为语义分割的输出是要给人看的,块与块之间的接缝、边界点的归属,都是实打实的问题。这一章把训练脚本、滑窗推理和可视化串起来。
4.1 训练配置与目录组织
超参上我不建议一上来就调,先用一套稳的跑通:Adam,lr=1e-3,betas=(0.9, 0.999),每 20 个 epoch 乘 0.7,batch 16~32(取决于块大小和显存),200 epoch 左右收敛。BatchNorm 的 momentum 设 0.5、衰减 0.5 是 PointNet 系列的常规做法,因为点云 batch 内统计量波动比图像大。数据增强用绕 Z 轴随机旋转、随机缩放 0.9~1.1、随机丢弃部分点,别做翻转——车体系下左右翻转会把场景变成右舵,语义上不成立。
python train_seg.py \ --data-root /path/to/vkitti3d_npz \ --split splits/train_seq.txt \ --block 1024 --batch-size 24 --epochs 200 \ --lr 1e-3 --lr-decay 0.7 --decay-step 20 \ --in-ch 6 --num-class 13 --ignore-index 255 \ --out runs/pn_seg_v1参数里--split我特意做成按 sequence 列表的文件,而不是按帧随机划分,原因在第 5 章会讲。--out目录下每次训练落三样东西:best.pth(按验证 mIoU 存)、last.pth、以及一个train.log记录每个 epoch 的 loss、学习率、验证 mIoU 和每类 IoU。日志比权重重要,出问题时你只能靠它回溯。
4.2 整帧推理:重叠滑窗 + 概率投票
推理时把一帧点云按 XY 网格切块,块与块之间留 50% 重叠,每个点会被预测多次,最终取平均概率的 argmax。重叠是必须的:不重叠的话,落在块边缘的点只看到半个邻域,语义边界会呈现明显的方格状锯齿。
@torch.no_grad() def infer_frame(model, points, colors, block=1024, stride=0.4, device="cuda"): model.eval() xy = points[:, :2] key = np.floor(xy / stride).astype(np.int32) # 用更细的网格做滑窗锚点 prob = np.zeros((len(points), 13), dtype=np.float32) cnt = np.zeros(len(points), dtype=np.float32) for g in np.unique(key, axis=0): # 以每个锚点为中心取邻域,半径约 block 覆盖范围 d = np.linalg.norm(xy - (g * stride + stride / 2), axis=1) idx = np.argsort(d)[:block] if len(idx) < 16: continue x = np.concatenate([points[idx], colors[idx] / 255.0], axis=1) x = torch.from_numpy(x.T[None]).float().to(device) p = torch.softmax(model(x), dim=1)[0].cpu().numpy().T # (n, 13) prob[idx] += p cnt[idx] += 1 cnt[cnt == 0] = 1 return (prob / cnt[:, None]).argmax(1)这里stride=0.4是锚点间距,比块覆盖范围小得多,所以重叠率很高,代价是推理慢 3~5 倍。实际项目里可以先跑stride=0.8看效果,边界问题明显再加密。cnt那一行是防止某些孤立点一次都没被覆盖到,这些点的预测没有意义,脚本里应该单独打印出来统计比例。
4.3 结果落盘与 HTML 预览页
评估和汇报的时候,光有数字说服不了人。我一般每帧导出一个轻量 JSON,包含点坐标(降采样到 3 万点左右)、RGB 和预测类别,再配一个单页 HTML 用 WebGL 渲染,鼠标拖着就能转,比截图直观得多。
import json def export_preview(points, colors, pred, path, max_pts=30000, rng=None): rng = rng or np.random.default_rng(0) sel = rng.choice(len(points), min(max_pts, len(points)), replace=False) payload = { "points": points[sel].round(2).tolist(), "colors": (colors[sel] // 8).astype(int).tolist(), # 量化到 5 bit,体积减半 "labels": pred[sel].astype(int).tolist(), } with open(path, "w") as f: json.dump(payload, f, separators=(",", ":"))颜色量化到 5 bit 是为了控制文件体积,3 万点的 JSON 一般在 1~2 MB,浏览器打开不卡。标签单独存一份,方便在页面上切换"按颜色显示"和"按类别着色"。这类 HTML 预览页不需要后端,丢到任意静态目录就能看,给不熟点云的人看结果时特别好用。
5. 避坑实录:四个让 mIoU 悄悄掉点的细节
这一章四条全是踩过的,共同特征是:不报错、loss 正常、结果看起来"也还行",但离正确差得很远。
5.1 深度缩放错 100 倍,loss 照样漂亮地往下降
现象:训练 loss 从 2.4 平稳降到 0.3,验证 mIoU 有四十多,但可视化出来的点云像一堵压扁的墙,远处物体全叠在一起。
原因:DEPTH_SCALE用了错的系数,整帧深度被统一放大或缩小两个数量级。因为反投影时 X、Y 都由 Z 线性导出,整片点云只是被等比缩放,再做一次归一化之后,输入网络的分布几乎没变,所以网络照样能学,只是学的是"缩放后的几何"。
解决:把DEPTH_SCALE提成配置项,先用一帧验证——找一辆已知距离的前车,量一下点云里它到原点的距离,对得上再往下走。这个检查我写成了一个独立脚本,每次换数据版本必跑。
5.2 内参当常量用,远处点云糊成一团
现象:近处路面和车身正常,远处的建筑边缘发散、护栏糊成一片。
原因:内参不是常量。图像如果做过缩放或裁剪,fx/fy/cx/cy必须同步变换(缩放时乘比例,裁剪时减偏移);合成序列的相机还有俯仰角变化,某些版本给的位姿是相机到车体的外参,有些是相机到世界,弄混了远处误差会被放大。
解决:内参按帧从文件读取,写一个scale_intrinsics(K, sx, sy, dx, dy)统一处理,所有预处理都走它。裁剪参数和缩放比例写进配置文件,别写在代码里。
5.3 调色板容差没调好,护栏整类消失
现象:训练日志里 GuardRail 的 IoU 一直是 0,混淆矩阵里这一类压根没有真值。
原因:颜色匹配的容差设得太小(精确等值匹配),护栏这种细长物体在渲染边缘的抗锯齿像素全被丢成 ignore,剩下的点本来就少,采样时又被 Road 挤掉。
解决:容差从 0 调到 8~12,同时统计每个类的原始点数、匹配失败点数、进训练集的点数三级漏斗。任何一个类的漏斗掉超过一半,都要单独查。
5.4 按帧随机划分,mIoU 虚高十几个点
现象:验证集 mIoU 比预期高很多,换一个 sequence 测试就掉一大截。
原因:VKITTI3D 同一个 sequence 的不同天气变体,几何结构是完全一样的,只是光照、材质、雨雾效果不同。按帧随机划分会把同场景的 clone 版放进训练、rain 版放进验证,模型实际上是在"见过的场景"上评估,自然虚高。
解决:按 sequence 划分 train/val/test,保证验证集和测试集的场景几何在训练时完全没出现过。如果想评估天气鲁棒性,那就明确写成"同场景跨变体"实验,单独报一组数字,不要混进主指标。
5.5 出问题时的排查顺序
我把上面这些整理成一个固定顺序,出问题按这个走,基本能在半小时内定位:先单帧可视化,看几何形状对不对;再看点数漏斗(原始点数→有效深度点数→匹配上标签的点数→进训练集点数);然后看类别直方图,训练集和验证集各画一份;最后才看混淆矩阵,哪一类跟哪一类混。跳过前三步直接看混淆矩阵,往往是白忙。
6. 用六种天气变体做交叉验证与 KNN 上采样
训练跑通、mIoU 稳定之后,这套资源还能再挖两层价值。
第一层是利用六种天气变体做交叉验证。我一般组三组实验:clone 训练、clone 验证(基线);clone 训练、rain+fog 验证(鲁棒性);clone+rain 训练、fog 验证(跨变体泛化)。这三组数字放在一起,能看出模型到底学到了几何还是学到了颜色纹理——如果第二组掉得特别厉害,说明模型大量依赖 RGB 通道里的光照线索,这时候就该做通道消融,把纯 XYZ 的结果也跑一遍对比。
| 实验组 | 训练变体 | 验证变体 | 主要看什么 |
|---|---|---|---|
| A 基线 | clone | clone | 上限与收敛速度 |
| B 跨天气 | clone | rain / fog | 对光照纹理的依赖程度 |
| C 混合训练 | clone + rain | fog | 数据增广的真实收益 |
| D 纯几何 | clone(XYZ only) | clone | RGB 贡献了多少点 |
第二层是推理端的 KNN 上采样。VKITTI3D 反投影出来的点云在远处非常稀疏,一帧里近处每平方米几百点、远处每平方米个位数,直接滑窗推理会让远处整片区域落到同一个类别。我的做法是推理前对稀疏区域做一次 KNN 插值补点,插值出来的点不参与评估,只用来给邻域提供上下文,预测完再把补的点丢掉。
from sklearn.neighbors import NearestNeighbors def densify(points, colors, k=3, radius=0.6, max_new=20000): """在稀疏区域插值补点,仅用于推理上下文,不参与评估""" nn = NearestNeighbors(n_neighbors=k + 1).fit(points) dist, idx = nn.kneighbors(points) sparse = dist[:, -1] > radius # 第 k 个邻居都超过 radius,视为稀疏 src = points[sparse] if len(src) == 0: return points, colors nb = points[idx[sparse, 1:]] # (M, k, 3) w = 1.0 / (dist[sparse, 1:] + 1e-6) w = w / w.sum(1, keepdims=True) new_pts = (nb * w[..., None]).sum(1)[:max_new] new_col = colors[idx[sparse, 1:]].mean(1).astype(colors.dtype)[:max_new] return np.concatenate([points, new_pts]), np.concatenate([colors, new_col])radius=0.6是我在城区场景下的经验值,对应"半米内没有三个邻居"就补点;max_new控制上限,防止空旷场景补出几十万点把显存撑爆。这个技巧在护栏、交通标志这类细长小目标上提升最明显,我自己的记录里,补点前后这几类的召回能差出一截,但补点参数一定要在验证集上定,别拿测试集调。
最后说个习惯。这套东西我前后改过三版,第一版直接把所有预处理写在一个大脚本里,第二版拆成模块但没写中间结果落盘,直到第三版才养成"每处理一帧先落 npy、每换一次数据版本先跑单帧可视化"的规矩。那之后我每次动数据管线,哪怕只改一行内参读取,都强制先跑一遍单帧检查和点数漏斗,确认几何没歪再开训练。希望帮到你。
本文还有配套的精品资源,点击获取