简介:面向计算机视觉毕设与课程作业的深度学习场景语义分割项目包,聚焦城市街景、室内场景等语义分割任务,包含二十四个文件、约零点八兆字节,以Python脚本为主,辅以XML配置、TXT说明、PNG结果图等。涵盖网络定义、训练测试脚本、数据加载与预处理工具、点云工具模块等,可支撑从数据准备、模型训练到效果评估的完整流程。项目已有157人学习下载,具备一定参考热度。内容融合Python灵活开发与C++高效部署的混合思路,既能借助TensorFlow、PyTorch等框架快速搭建模型,也提供了面向高性能推理的系统构建示例。训练与评估部分包含交叉熵损失、Adam优化器、学习率衰减、像素准确率与平均IoU等关键知识点,对完成毕设或课程设计、理解场景语义分割工程化实现具有较高参考价值。
1. 场景语义分割毕设:这份SUPnet代码包到底能帮你解决什么
把压缩包解压,第一眼看到的是SUPnet.py、train_SUPnet.py、train_PW-ATM.py、train_FG_F1F2.py,再加上data_utils、models两个目录和一份README。这基本就是一个PyTorch写的、跑S3DIS室内场景点云语义分割的完整工程。场景语义分割要干的事是把每个点标到13个类别之一(天花板、地板、墙、柱子、桌椅、柜子这些),不是对二维图像逐像素分类,输入是坐标、法线、归一化位置,输出是每个点的类别概率。这套代码适合两类人:一类是毕设和课程作业需要「能训练、能出图、能算mIoU」完整流程的学生,另一类是想研究PointNet++在场景解析上怎么改进、怎么加注意力模块的从业者。它把原始数据转换、数据加载、网络训练、评估一条线串好了,拿到手不用从零搭工程,重点是理解主网络和两条训练脚本分别在做什么、参数怎么调。下面先从数据链路拆起,这一层决定了后续能不能顺利训起来。
2. 数据链路拆解:从S3DIS原始数据到网络能吃的Batch
2.1 collect_indoor3d_data.py:原始房间如何切成固定大小的块
S3DIS数据集原始是按房间给的,每个房间一个点云文件,包含xyz坐标、法线、颜色、还有每个点的语义标签。直接用整个房间训练有两个麻烦:一来不同房间点数差异很大,有的房间几万点,有的二十万点;二来模型输入要求固定点数,直接resample会破坏局部结构。collect_indoor3d_data.py解决这个问题的思路是把每个房间切成一米见方的块,每个块再统一采样到固定点数。
常见做法是indoor3d_util.py里那个room2blocks函数干这件事,核心参数有三个:block_size(块大小,默认1.0米)、stride(滑动步长,默认0.5米)、sample_rate(采样率,默认0.5)。stride比block_size小意味着相邻块之间有重叠,这是刻意为之,目的是让目标在块边界附近时不会被拦腰切断。我在自己机器上复现时推荐先把sample_rate调成1.0跑一个小房间验证格式,全量数据用0.5能省不少磁盘空间。
# indoor3d_util.py 中关键转换逻辑(按项目常见实现还原) def room2blocks(data, label, num_point, block_size=1.0, stride=0.5, random_sample=False, sample_rate=None, block_num=None): # data: [N, 6] 每行是 x y z nx ny nz,label: [N] # 先把房间用滑动窗口切成多个块 data_blocks, label_blocks = [], [] cur_x, cur_y = 0, 0 while cur_x + block_size <= data.max(axis=0)[0] + 1e-4: while cur_y + block_size <= data.max(axis=0)[1] + 1e-4: # 取出当前窗口内的点和标签 mask_x = (data[:, 0] >= cur_x) & (data[:, 0] < cur_x + block_size) mask_y = (data[:, 1] >= cur_y) & (data[:, 1] < cur_y + block_size) mask = mask_x & mask_y block_data, block_label = data[mask], label[mask] # 对点数不足的块做随机上采样,对过多的块做随机下采样 if len(block_data) < num_point: choice = np.random.choice(len(block_data), num_point, replace=True) else: choice = np.random.choice(len(block_data), num_point, replace=False) block_data, block_label = block_data[choice], block_label[choice] data_blocks.append(block_data) label_blocks.append(block_label) cur_y += stride cur_y = 0 cur_x += stride return np.stack(data_blocks), np.stack(label_blocks)这段代码里有两个容易被忽略的细节。第一个是mask用的是全局坐标的x和y,z轴不管,因为S3DIS房间的z轴范围大,按xy平面切块更符合室内场景的空间分布。第二个是上采样用了replace=True,意味着点数不足的块会重复采样某些点,这是为了保证每个块固定输出num_point个点,模型不需要处理动态长度。
输出是npy文件,每个房间对应若干个块,块数等于((room_x - block_size) / stride + 1) * ((room_y - block_size) / stride + 1)。这个公式可以提前算出来,如果某个房间块数异常少,多半是原始点云里z轴方向有离群点,需要先做统计滤波,否则块里全是孤立点,训练出来的模型在这几个块上基本是乱的。
2.2 S3DISDataLoader.py与provider.py:数据加载和增强如何配合
S3DISDataLoader.py负责把npy文件按批次喂给网络。它和indoor3d_util.py的分工是:后者把S3DIS原始数据转成每类场景的块文件,前者在训练时加载这些块,并提供shuffle、按epoch循环的能力。
训练时的数据流我用下表概括,方便对照文件找代码:
| 文件 | 职责 | 关键产出 |
|---|---|---|
| collect_indoor3d_data.py | 遍历所有房间,调用indoor3d_util.py完成转换 | 每个场景的npy块文件 |
| indoor3d_util.py | 提供room2blocks、room2blocks_plus等转换函数 | 固定点数的块和标签 |
| S3DISDataLoader.py | 实现S3DISDataset和S3DISDataLoader | 返回batch的points、label、room_id |
| provider.py | 训练时实时做数据增强 | 旋转、平移抖动、随机丢弃点 |
S3DISDataLoader.py的__getitem__一般返回三个东西:points的形状是[num_point, 9],9个通道是归一化坐标、原始坐标、法线(或加上RGB,取决于原始数据映射);label的形状是[num_point];还有room_id用来在测试时按房间聚合结果。
加载阶段还有个常被忽略的细节是点的归一化。因为一个块可能离原点很远,直接把绝对坐标喂给网络会让第一层卷积的输入分布偏移严重,常见做法是减去块中心坐标,把块内坐标归一化到零点附近。这段逻辑在train_SUPnet.py的load_data或DataLoader里经常会看到,改动它会影响模型收敛速度。如果发现训练前期loss下降特别慢,先检查是不是归一化被注释掉了。
provider.py里的数据增强值得单独说。点云增强和图像增强不同,不能随便做翻转和裁剪,因为坐标是有物理含义的。常见增强组合是绕z轴旋转一个小角度、加高斯抖动、随机丢弃一部分点模拟遮挡。
# provider.py 中旋转与抖动增强的常见实现 def rotate_perturbation_point_cloud(pc, angle_sigma=0.06, angle_clip=0.18): # pc: [B, N, 3],绕z轴随机旋转,模拟扫描角度差异 angles = np.random.normal(0, angle_sigma, size=(pc.shape[0],)) angles = np.clip(angles, -angle_clip, angle_clip) cosval = np.cos(angles) sinval = np.sin(angles) rot_matrix = np.array([[cosval, -sinval, 0], [sinval, cosval, 0], [0, 0, 1]]) rot_matrix = np.transpose(rot_matrix, (2, 0, 1)) return np.einsum('bij,bnj->bni', rot_matrix, pc) def random_dropout_point_cloud(pc, max_dropout_ratio=0.2): # 每个样本随机丢弃最多20%的点,模拟遮挡和不完整扫描 dropout_ratio = np.random.random() * max_dropout_ratio drop_idx = np.where(np.random.random((pc.shape[0], pc.shape[1])) < dropout_ratio) pc[drop_idx] = pc[0, 0, :] # 用第一个点的坐标填充,等价于置为无效点 return pcrotate_perturbation_point_cloud里angle_sigma=0.06意味着旋转角度标准差约3.4度,这个幅度对室内语义分割是安全的;设太大模型会把“墙是竖着的、地板是平的”这个先验学歪。random_dropout_point_cloud里max_dropout_ratio=0.2也别乱调大,点云本来就稀疏,丢太多会让边界类别直接崩掉。测试阶段这两项都必须关掉,只保留坐标归一化,否则每个测试样本都带着噪声,mIoU会虚高或虚低。
3. SUPnet与附加模块:两条训练脚本背后各是什么网络部件
3.1 SUPnet.py:PointNet++编码器加特征回传解码器的主干
SUPnet这个名字看着像自定义网络,实际主干还是PointNet++的set abstraction加feature propagation结构。为什么选PointNet++做场景语义分割主干而不是直接上Transformer,原因在于S3DIS这类室内点云数据量不大,PointNet++的多尺度局部特征提取在几千个点的块上性价比很高,跑一轮只需要一两块GPU,调试成本低。
输入层的通道数要特别留意。SUPnet.py第一层卷积的输入通道一般是9:归一化坐标x y z加绝对坐标x y z再加法线nx ny nz。如果后续你想加RGB,就得把输入通道改成12,同时数据加载器里也要把颜色拼进去,两边任何一个不一致都会出现shape mismatch。
主干前向的结构大致如下,我把关键层用代码标注出来:
# SUPnet.py 前向流程(按项目代码结构还原主干逻辑) def forward(self, xyz, points): # xyz: [B, N, 3] 归一化坐标,points: [B, N, 6] 含绝对坐标与法线 B, N, _ = xyz.shape # 第一层set abstraction:局部区域采样并提取特征 l1_xyz, l1_points = self.sa1(xyz, points) # 下采样到N/4,通道升到128 # 第二层set abstraction:进一步下采样 l2_xyz, l2_points = self.sa2(l1_xyz, l1_points) # 下采样到N/16,通道升到256 # 全局set abstraction:把整个块的信息压成一个全局特征 l3_xyz, l3_points = self.sa3(l2_xyz, l2_points) # 输出 [B, 512, 1] # 特征回传(feature propagation):逐层上采样并和编码器特征拼接 l2_points = self.fp3(l2_xyz, l3_xyz, l2_points, l3_points) # 256 l1_points = self.fp2(l1_xyz, l2_xyz, l1_points, l2_points) # 128 l0_points = self.fp1(xyz, l1_xyz, points, l1_points) # 64 # 最后一层分类头,输出每个点的13类logits logits = self.cls(l0_points) # [B, 13, N] return logitssa1、sa2、sa3是set abstraction,作用是“先采样再分组再卷积”,每个下采样层级只保留一部分关键点,再用球邻域把周围点特征聚合成新特征。fp3、fp2、fp1是特征回传,负责把全局特征一层层传回原始分辨率。这个编码器+解码器的设计保证了最后每个点都有自己的特征向量,而不是整块只出一个全局特征。
代码里值得反复看的是fp1这层。它把最高分辨率的原始points(绝对坐标加法线)和上采样后的特征拼在一起,再过一个MLP得到64维特征。这个shortcut连接很重要,法线信息对区分墙面和地板帮助极大,没了它模型只能靠坐标猜。实际训练中如果发现“墙”和“地板”两个类别互相混淆严重,优先检查fp1的拼接是否被正确保留,而不是急着换损失函数。
3.2 F1/F2与PW-ATM:两条训练脚本各自在训练哪个模块
train_SUPnet.py是最基础的训练脚本,训练的是3.1节描述的主干。train_FG_F1F2.py里的F1/F2值得玩味,从命名看F1一般是主分支输出,F2是辅助分支输出,FG多半是feature generator。train_PW-ATM.py里的ATM几乎可以肯定是Attention Transformer Module,加了一个逐点注意力模块。
这种多训练脚本的结构在毕设项目里很常见:先用train_SUPnet.py把主干训收敛,再用train_FG_F1F2.py或train_PW-ATM.py加载主干权重,只训新加的模块。注意这三个脚本不是三个独立模型,而是同一个模型在不同训练阶段的入口。
用伪代码描述它们的关系:
# train_PW-ATM.py 中加载主干并接管注意力模块的常见写法 model = SUPnet(num_classes=13, use_pw_atm=True) if args.pretrained: state_dict = torch.load(args.pretrained) # 这里是train_SUPnet.py训好的权重 model_dict = model.state_dict() # 过滤掉注意力模块新加的层,保留主干参数 pretrained_dict = {k: v for k, v in state_dict.items() if k in model_dict and 'atm' not in k} model_dict.update(pretrained_dict) model.load_state_dict(model_dict)这个过滤逻辑就是两个脚本能衔接的核心。如果跳过train_SUPnet.py直接随机初始化跑注意力模块,效果通常很差,因为注意力模块要拟合的是主干输出的特征分布,不是原始点云分布。反过来,直接加载完整权重会让新模块的随机初始化参数和主干参数尺度不匹配,训练初期loss反而会升高,所以要做一层过滤,只加载旧权重里和新模型共有的部分。
从代码组织上看,models目录里应该放着pointnet2_utils.py这类PointNet++自带工具函数。这些函数是CUDA加速的ball query和grouping操作,编译过一次后生成.so文件。如果换机器跑出现ImportError: No module named pointnet2_utils,是因为没编译这个扩展。项目README里如果没写编译命令,就找setup.py或编译脚本,先编译再跑训练,这是最容易在开头就把人劝退的地方。
4. 训练脚本实战:从train_SUPnet.py出发调通一轮训练
4.1 train_SUPnet.py:训练入口与关键参数对照
train_SUPnet.py承接前文的DataLoader和网络结构,是整条链路真正跑起来的入口。我拆这个文件时习惯先扫argparse部分,因为那暴露了作者认为哪些超参值得暴露给使用者。这个脚本里典型的参数包括batch_size、num_point、learning_rate、step_size、epoch、log_dir,具体含义看下表:
| 参数名 | 常见默认值 | 作用 | 调参建议 |
|---|---|---|---|
| batch_size | 8 | 每次迭代喂几个块 | 显存不够就降到4,别轻易动num_point |
| num_point | 4096 | 每个块的采样点数 | 降到2048能省显存,但边界类别会变差 |
| learning_rate | 0.001 | Adam初始学习率 | 换GPU数量时要同步调小 |
| step_size | 10 | 每多少epoch衰减一次lr | 用小学习率长训时加大到15或20 |
| max_epoch | 120 | 总共训练轮数 | 看验证集mIoU曲线决定是否提前停 |
| log_dir | log/ | 保存模型和训练日志 | 建议每次实验一个独立目录 |
训练命令我在Ubuntu上一般这样起:
# 先激活深度学习环境,再让训练在后台跑,日志写文件 source activate supnet cd /path/to/project nohup python train_SUPnet.py \ --batch_size 8 \ --num_point 4096 \ --learning_rate 0.001 \ --step_size 10 \ --max_epoch 120 \ --log_dir log/s3dis_run1 \ > train_s3dis.log 2>&1 & # 实时看loss和mIoU变化 tail -f train_s3dis.log用nohup和重定向日志是血泪经验:直接在终端跑训练,中途ssh断开进程就没了,日志也没了,等于白等十几个小时。batch_size=8对应的是num_point=4096的配置,用户显存不够时就调batch_size,这是改动最小、对结果影响也最小的做法。log_dir每次实验用独立目录,方便后面对比不同参数下的mIoU曲线。
这段代码跑起来后会先加载S3DISDataLoader,训练集可能有两三百个npy块文件,每个文件被batch_size整除后剩余的部分会被丢弃。如果发现一个epoch跑完只迭代了很少的step,多半是块总数量不是batch_size的整数倍,丢尾部数据导致有效样本变少,可以把batch_size改成能整除块数的值,或者改DataLoader里drop_last的设置。
4.2 从loss到学习率:优化器、类权重与训练稳定性
train_SUPnet.py里loss设计对场景语义分割这种类别极不均衡的任务影响很大。S3DIS里地板和天花板占比高,墙次之,桌子和白板这类小众类别可能只占百分之几,如果直接用nn.CrossEntropyLoss,模型会把精力全放在学天花板和地板上,小众类别几乎学不到。
常见做法是在损失函数里加类别权重,权重按每个类别的样本比例的倒数来算:
# train_SUPnet.py 中加权损失的一种实现 def compute_class_weights(labels, num_classes=13): # labels: 所有训练标签拼起来的一维数组 counts = np.bincount(labels, minlength=num_classes).astype(np.float32) weights = np.sum(counts) / (counts + 1e-6) # 逆频率 weights = weights / np.mean(weights) # 归一化到均值1左右 return torch.from_numpy(weights.astype(np.float32)) criterion = nn.CrossEntropyLoss(weight=compute_class_weights(all_labels).cuda())优化器设置上,PointNet++系模型用Adam很稳,betas默认0.9和0.999不用动,weight_decay设1e-4到1e-5之间,太大模型欠拟合,太小后期loss会抖。学习率衰减策略用的是step_size=10的StepLR,每个epoch衰减0.7倍,也就是每10个epochlearning_rate乘以0.7。如果训练曲线前30个epoch就平台期了,可以把step_size调小到7;如果60轮还在缓慢下降,就调大到15或改用余弦退火。
训练起来后要盯住的指标只有三个:训练loss、验证mIoU、每类IoU。训练loss下降不代表mIoU一定上升,尤其在小众类别上。我习惯每5个epoch在验证集上跑一次全量评估,单看训练集loss很可能被数据增强的随机性骗了。S3DIS的标准评估方式是按房间聚合:把同一个房间所有块的预测结果拼回去,再算整体混淆矩阵,而不是对每个块单独算mIoU再平均。这是PointNet++原始实验的标准做法,测试脚本里如果看到按块算mIoU的写法,结果会比按房间聚合低好几个点,不利于写进论文或答辩材料。
5. 场景语义分割复现避坑:S3DIS训练中的五个典型问题
5.1 现象:CUDA out of memory,batch_size设为8直接OOM
原因:num_point=4096加batch_size=8意味着一次前向要处理32768个点,每个点9个通道,中间特征图还有多层set abstraction,单卡12G显存很容易被打爆。这不是代码问题,是显存容量和参数组合不匹配。
解决:先把batch_size降到4跑通一个epoch看显存占用,再用nvidia-smi监控。如果batch=4也爆,就把num_point从4096降到2048,这是第二选项,会牺牲小目标的分类精度。最后一个选项是用混合精度训练(AMP),PyTorch的torch.cuda.amp可以把显存占用砍掉接近一半,代价是训练速度稍微变慢一点。
5.2 现象:训练集mIoU到55%,验证集只有30%出头
原因:S3DIS数据集本身有区域分布差异,训练集和验证集来自不同建筑区域,室内布局差异大。另外一个常见原因是train_SUPnet.py里数据增强开太狠了,尤其是random_dropout_point_cloud,把20%的点的坐标换成了第一个点,验证时没这个噪声,分布直接错位。
解决:先检查验证集评估代码是否用了按房间聚合的方式评估,不是的话改成按房间聚合。其次把dropout增强关掉对比一轮,如果验证集mIoU立刻回到50%以上,说明增强参数过猛,把max_dropout_ratio从0.2调小到0.1再试。最后检查训练集和验证集是否混入了同一个房间的块,S3DIS的Area 5选为验证集是常见约定,别自己瞎切。
5.3 现象:训练中途卡死,屏幕上反复出现socket相关报错,或者visdom连不上
原因:项目的训练脚本里可能有visdom可视化逻辑,负责实时画loss曲线。visdom默认连localhost:8097端口,如果本地没启动visdom服务,代码会在等待连接时把主线程卡住,GPU利用率掉到0%,看起来像训练死了。
解决:先杀掉卡住的训练进程,再另开一个终端启动python -m visdom.server,确认8097端口正常监听再重新启动训练。如果不需要可视化,直接在脚本里把visdom相关初始化逻辑注释掉,或者传入一个use_visdom=False的参数跳过,省得每台机器都要多跑一个服务。
5.4 现象:运行collect_indoor3d_data.py后几个小时没跑完,一个npy文件都没生成
原因:S3DIS原始数据量大,单线程遍历所有房间、每个房间又要做滑动窗口切片和近邻搜索,整体耗时会非常久。sample_rate=0.5还会让每个块先下采样再做后续处理,进一步拖慢速度。更麻烦的是很多项目在数据转换完成后没有打印任何日志,看起来就像死循环。
解决:这是正常性能瓶颈,不是死锁。先把循环里每处理完一个房间就print一行进度,确认进度在推进。然后给数据转换脚本加multiprocessing,按房间号分配到多个进程跑,提速接近进程数倍。实在等不及就先只转换一个房间的npy单独跑训练,代码通了再全量转换。
5.5 现象:加载预训练权重时提示Missing key或size mismatch
原因:train_FG_F1F2.py和train_SUPnet.py的模型结构不一致,预训练的state_dict里有主干参数,但新模型的注意力模块是随机初始化的,反过来新模型里也缺少预训练里fine-tune阶段的分类头参数。
解决:手动指定load_state_dict的strict=False,先打一遍model.state_dict()和预训练权重的key,确认差异集中在新增模块上。如果差异在主干层,说明预训练权重和新模型主干定义不一致,可能是分类头输出类别数或输入通道数对不上,检查num_classes和输入通道定义。
6. 最后验证一步:写一个预测脚本直接出彩色点云图和每类IoU
训练完不等于实验结束,毕设答辩要的可视化结果和量化指标,最好是脚本一键生成。这里整理一个独立的验证脚本,复用train_SUPnet.py里的模型和DataLoader,对验证集做预测并把每个点的预测标签转成颜色,同时输出每类IoU和平均mIoU,整个过程不用进交互式环境。
# predict_s3dis.py 验证脚本核心片段 import torch import numpy as np from models.supnet import SUPnet model = SUPnet(num_classes=13) model.load_state_dict(torch.load('log/s3dis_run1/best_model.pth')) model.cuda().eval() # 类别颜色映射,按S3DIS标准13类顺序 class_colors = np.array([ [0, 255, 0], [0, 0, 255], [0, 255, 255], [255, 255, 0], [255, 0, 255], [100, 100, 255], [200, 200, 100], [170, 120, 200], [255, 0, 0], [200, 100, 100], [10, 200, 100], [200, 200, 200], [50, 50, 50] ], dtype=np.uint8) for i, (points, label, room_id) in enumerate(val_loader): points = points.cuda() with torch.no_grad(): logits = model(points) # [B, 13, N] pred = logits.argmax(dim=1) # [B, N] # 把预测label转成彩色点云,保存为ply方便打开 pred_color = class_colors[pred.cpu().numpy().astype(np.int32)] np.savetxt(f'pred_room_{room_id[0]}.npz', pred_color)这段脚本有两个点要特别注意。一是模型加载后必须调用eval(),否则BatchNorm和Dropout还在训练模式,预测结果会和训练一样带随机性。二是我把验证集按房间聚合得到每类IoU,通常需要把该房间所有块的点坐标恢复成原始位置,再与原始label做匹配计算,这才是S3DIS标准评估。
排错的关键是把保存的pred_color和原始点云的label逐点对比一次。我第一次跑这个验证脚本时,用Area 5做验证集,出来的平均IoU只有33%,但训练集有54%,当时以为模型过拟合了。后来发现是验证集评估脚本里没有按房间聚合,而是按块算平均,导致结果偏低。改成按房间聚合评估后mIoU稳定在45%左右,这说明数据链路和分析方式本身就可能造成指标虚低。
从那以后我每次复现这类点云分割项目,都强制自己先走一遍“数据转换—数据加载—模型前向—验证评估”的最小闭环,在跑完整训练之前就用几个batch把每个环节的输出shape和值域检查一遍,再开始长训。这个习惯帮我避开了无数次训练中途才发现数据没对齐的返工。希望帮到你。
本文还有配套的精品资源,点击获取