• 点云深度学习系列博客(四):PointNet代码精讲


    目录

    1. 代码解析

    1.1 初始化

    1.2 数据载入

    1.3 模型载入

    1.4 训练代码

    2. 实验结果

    Reference


    最近开始上手点云深度学习项目,相比之前纸上谈兵的阶段,此时我将把更多的精力放在代码学习和复现上。在新的学习阶段,就不能是看看论文,蜻蜓点水的配下别人的代码这么简单了。我将逐句分析代码功能,结合实际应用,来深入理解点云深度学习的项目该如何落地。作为点云深度学习的代表作,PointNet [1] 的经典程度不言而喻。我们就以PointNet的模板,来展开相关代码的实现,并完全复现PointNet的基本功能。对于那些计划零基础入坑点云深度学习的同学,不妨看看。


    1. 代码解析

    我们的解析基于项目:https://github.com/yanx27/Pointnet_Pointnet2_pytorch

    使用的平台为Pycharm2021+python3.8+pytorch12.1+cu116。

    我们以train_classification.py项目为例,来介绍代码的实现细节。这里使用的数据库为ModelNet。

    1.1 初始化

    首先我们看看在正式训练之前,程序都做了什么事情。按照项目文档,参数化调用:

    python train_classification.py --model pointnet2_cls_ssg --log_dir pointnet2_cls_ssg

    按照函数parse_args()进行解析,具体为:

    --use_cpu cpu模式选择

    --gpu gpu模式选择

    --batch_size 数据块尺度

    --model 指定训练模型

    --num_category 分类数目

    --epoch 数据扫描次数

    --learning_rate 学习率

    --num_point 点云采样点数

    --optimizer 优化器,默认选择

    --log_dir 实验根目录

    --decay_rate 衰减率

    --use_normals 是否使用法线

    --process_data 是否离线处理数据

    --use_uniform_sample 是否使用均匀采样

    标红的项为对应之前参数化调用的两项。接下来,我们看main函数的内容。首先,我们介绍获取参数调用后的一些初始设置,包括路径设置以及日志文件设置。

    1. #存储可用的gpu
    2. os.environ["CUDA_VISIBLE_DEVICES"] = args.gpu
    3. timestr = str(datetime.datetime.now().strftime('%Y-%m-%d_%H-%M'))
    4. #设置日志文件路径
    5. exp_dir = Path('./log/')
    6. exp_dir.mkdir(exist_ok=True)
    7. exp_dir = exp_dir.joinpath('classification')
    8. exp_dir.mkdir(exist_ok=True)
    9. #建立log路径,如果没有给定路径名,建立以时间为名的路径
    10. if args.log_dir is None:
    11. exp_dir = exp_dir.joinpath(timestr)
    12. else:
    13. exp_dir = exp_dir.joinpath(args.log_dir)
    14. exp_dir.mkdir(exist_ok=True)
    15. checkpoints_dir = exp_dir.joinpath('checkpoints/')
    16. checkpoints_dir.mkdir(exist_ok=True)
    17. log_dir = exp_dir.joinpath('logs/')
    18. log_dir.mkdir(exist_ok=True)
    19. #这一部分用于存储log信息,将时间以及相关参数存在(log_dir/args.model).txt中
    20. logger = logging.getLogger("Model")
    21. logger.setLevel(logging.INFO)
    22. formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
    23. file_handler = logging.FileHandler('%s/%s.txt' % (log_dir, args.model))
    24. file_handler.setLevel(logging.INFO)
    25. file_handler.setFormatter(formatter)
    26. logger.addHandler(file_handler)
    27. log_string('PARAMETER ...')
    28. log_string(args)

    1.2 数据载入

    1. train_dataset = ModelNetDataLoader(root=data_path, args=args, split='train',
    2. process_data=args.process_data)
    3. test_dataset = ModelNetDataLoader(root=data_path, args=args, split='test',
    4. process_data=args.process_data)
    5. trainDataLoader = torch.utils.data.DataLoader(train_dataset, batch_size=args.batch_size,
    6. shuffle=True, num_workers=10, drop_last=True)
    7. testDataLoader = torch.utils.data.DataLoader(test_dataset, batch_size=args.batch_size,
    8. shuffle=False, num_workers=10)

    这里展开解释一下ModelNetDataLoader:

    1. def __init__(self, root, args, split='train', process_data=False):
    2. #首先初始化参数,包括根目录root,参数列表args,训练集或测试集指定,默认是训练集,以及
    3. 是否需要离线存储数据process_data
    4. self.root = root
    5. self.process_data = process_data
    6. #从参数列表中提取参数,包括点数,是否均匀化采样,是否使用法线,分类数
    7. self.npoints = args.num_point
    8. self.uniform = args.use_uniform_sample
    9. self.use_normals = args.use_normals
    10. self.num_category = args.num_category
    11. #这里判断下,如果是10类,就读取modelnet10_shape_names.txt,否则读取
    12. modelnet40_shape_names.txt
    13. if self.num_category == 10:
    14. self.catfile = os.path.join(self.root, 'modelnet10_shape_names.txt')
    15. else:
    16. self.catfile = os.path.join(self.root, 'modelnet40_shape_names.txt')
    17. #这里的self.cat类型是list,存储的是类别
    18. #这里的self.classes 类型是dict,存储的是类别和对应的编号
    19. self.cat = [line.rstrip() for line in open(self.catfile)]
    20. self.classes = dict(zip(self.cat, range(len(self.cat))))
    21. #这里的shape_ids类型是dict,存储的是train的list和test的list
    22. shape_ids = {}
    23. if self.num_category == 10:
    24. shape_ids['train'] = [line.rstrip() for line in open(os.path.join(self.root,
    25. 'modelnet10_train.txt'))]
    26. shape_ids['test'] = [line.rstrip() for line in open(os.path.join(self.root,
    27. 'modelnet10_test.txt'))]
    28. else:
    29. shape_ids['train'] = [line.rstrip() for line in open(os.path.join(self.root,
    30. 'modelnet40_train.txt'))]
    31. shape_ids['test'] = [line.rstrip() for line in open(os.path.join(self.root,
    32. 'modelnet40_test.txt'))]
    33. assert (split == 'train' or split == 'test')
    34. #从shape_ids这个dict中,按照split的指定提取shape名
    35. shape_names = ['_'.join(x.split('_')[0:-1]) for x in shape_ids[split]]
    36. #按照shape名设置父路径,并且把对应的model存储在对应的路径里
    37. self.datapath = [(shape_names[i], os.path.join(self.root, shape_names[i],
    38. shape_ids[split][i]) + '.txt') for i
    39. in range(len(shape_ids[split]))]
    40. print('The size of %s data is %d' % (split, len(self.datapath)))
    41. #这两句不知道什么意思,好像是设置了一个关于是否均匀采样的路径。但是我运行后,对应路径
    42. #没有发现.dat文件
    43. if self.uniform:
    44. self.save_path = os.path.join(root, 'modelnet%d_%s_%dpts_fps.dat' %
    45. (self.num_category, split, self.npoints))
    46. else:
    47. self.save_path = os.path.join(root, 'modelnet%d_%s_%dpts.dat' %
    48. (self.num_category, split, self.npoints))
    49. #下面相当长的一段是用来做离线数据处理的,主要用来统一点数,如果这里不做处理,那么就是
    50. #在训练的时候做处理。
    51. if self.process_data:
    52. if not os.path.exists(self.save_path):
    53. print('Processing data %s (only running in the first time)...' %
    54. self.save_path)
    55. self.list_of_points = [None] * len(self.datapath)
    56. self.list_of_labels = [None] * len(self.datapath)
    57. for index in tqdm(range(len(self.datapath)), total=len(self.datapath)):
    58. fn = self.datapath[index]
    59. cls = self.classes[self.datapath[index][0]]
    60. cls = np.array([cls]).astype(np.int32)
    61. point_set = np.loadtxt(fn[1], delimiter=',').astype(np.float32)
    62. if self.uniform:
    63. point_set = farthest_point_sample(point_set, self.npoints)
    64. else:
    65. point_set = point_set[0:self.npoints, :]
    66. self.list_of_points[index] = point_set
    67. self.list_of_labels[index] = cls
    68. with open(self.save_path, 'wb') as f:
    69. pickle.dump([self.list_of_points, self.list_of_labels], f)
    70. else:
    71. print('Load processed data from %s...' % self.save_path)
    72. with open(self.save_path, 'rb') as f:
    73. self.list_of_points, self.list_of_labels = pickle.load(f)

    我们知道,在进行网络训练的时候,为了能够在有限的存储结构基础上,实现对大规模数据集的训练,需要把数据打包成batch,然后再每一个batch上进行运算,并在最后组合不同的batch。对于Pontnet项目,给出的默认batch_size=24。那么,我们在训练的时候,需要把前一阶段载入的数据,按照batch_size进行整理,以方便网络使用,这里,项目使用torch.utils.data.DataLoader来完成相关功能,代码如下:

    1. trainDataLoader = torch.utils.data.DataLoader(train_dataset, batch_size=args.batch_size,
    2. shuffle=True, num_workers=10, drop_last=True)
    3. testDataLoader = torch.utils.data.DataLoader(test_dataset, batch_size=args.batch_size,
    4. shuffle=False, num_workers=10)

    shuffle(洗牌)表示是否在每一次训练时,重组数据;number_workers表示工作者数量,默认是0。使用多少个子进程来导入数据 drop_last表示是否丢弃最后一个不完整batch的数据。

    1.3 模型载入

    1. #载入模型
    2. num_class = args.num_category
    3. #动态导入对象,这里默认的是pointnet_cls
    4. model = importlib.import_module(args.model)
    5. #没啥用的shutil,建议删掉
    6. shutil.copy('./models/%s.py' % args.model, str(exp_dir))
    7. shutil.copy('models/pointnet2_utils.py', str(exp_dir))
    8. shutil.copy('./train_classification.py', str(exp_dir))
    9. #这里调用的get_model,即pointnet的网络结构
    10. classifier = model.get_model(num_class, normal_channel=args.use_normals)
    11. criterion = model.get_loss()
    12. #这里判断了一下激活函数是否是ReLu,如果不是,设置成ReLu
    13. classifier.apply(inplace_relu)

    不知道作者使用shutil.copy的原因,要把三个文件在log复制一遍。我在项目中把这个部分删掉了,试了一下,没有任何影响。importlib.import_module为动态导入对象。model.get_model即为提取pointnet的backbone。因为这里涉及到了model最核心的代码,所以要展开,如下:

    1. def __init__(self, k=40, normal_channel=True):
    2. super(get_model, self).__init__()
    3. if normal_channel:
    4. channel = 6
    5. else:
    6. channel = 3
    7. self.feat = PointNetEncoder(global_feat=True, feature_transform=True,
    8. channel=channel)
    9. self.fc1 = nn.Linear(1024, 512)
    10. self.fc2 = nn.Linear(512, 256)
    11. self.fc3 = nn.Linear(256, k)
    12. self.dropout = nn.Dropout(p=0.4)
    13. self.bn1 = nn.BatchNorm1d(512)
    14. self.bn2 = nn.BatchNorm1d(256)
    15. self.relu = nn.ReLU()
    16. def forward(self, x):
    17. x, trans, trans_feat = self.feat(x)
    18. x = F.relu(self.bn1(self.fc1(x)))
    19. x = F.relu(self.bn2(self.dropout(self.fc2(x))))
    20. x = self.fc3(x)
    21. x = F.log_softmax(x, dim=1)
    22. return x, trans_feat

    这20多行代码就是整个pointnet的backbone。可以看到结构是非常清晰的。这里项目的作者在输出最后分类的结果之前的MLP加了一个dropout,并把参数设为0.4。这是一个trick,用来减少过拟合。PointNetEncoder已经封装了自maxpooling以前的全部代码。因此,只需要加三个mlp全连接层,以补全网络的后段实现就可以。在pointnet_utils.py中存储着PointNetEncoder的结构:

    1. class PointNetEncoder(nn.Module):
    2. def __init__(self, global_feat=True, feature_transform=False, channel=3):
    3. super(PointNetEncoder, self).__init__()
    4. #PointNet的第一个T-Net
    5. self.stn = STN3d(channel)
    6. #使用一维卷积实现MLP,从64升维到1024
    7. self.conv1 = torch.nn.Conv1d(channel, 64, 1)
    8. self.conv2 = torch.nn.Conv1d(64, 128, 1)
    9. self.conv3 = torch.nn.Conv1d(128, 1024, 1)
    10. #参数归一化,配合MLP
    11. self.bn1 = nn.BatchNorm1d(64)
    12. self.bn2 = nn.BatchNorm1d(128)
    13. self.bn3 = nn.BatchNorm1d(1024)
    14. #这个用来判断是否复制全局特征(maxpooling后的1024维全局特征,分类问题就不用复制,分割
    15. 问题就需要复制,pointnet_sem_seg的设置就是需要复制的)
    16. self.global_feat = global_feat
    17. #按照参数设置,以判断是否需要特征对齐
    18. self.feature_transform = feature_transform
    19. if self.feature_transform:
    20. self.fstn = STNkd(k=64)
    21. def forward(self, x):
    22. B, D, N = x.size()
    23. trans = self.stn(x)
    24. x = x.transpose(2, 1)
    25. if D > 3:
    26. feature = x[:, :, 3:]
    27. x = x[:, :, :3]
    28. #从stn中获取变换矩阵trans,使用bmm批量变换x
    29. x = torch.bmm(x, trans)
    30. if D > 3:
    31. x = torch.cat([x, feature], dim=2)
    32. #转置
    33. x = x.transpose(2, 1)
    34. #这里是第一个T-Net之后的MLP
    35. x = F.relu(self.bn1(self.conv1(x)))
    36. #这里判断是否使用第二个T-Net
    37. if self.feature_transform:
    38. trans_feat = self.fstn(x)
    39. x = x.transpose(2, 1)
    40. x = torch.bmm(x, trans_feat)
    41. x = x.transpose(2, 1)
    42. else:
    43. trans_feat = None
    44. pointfeat = x
    45. #这里是第二个T-Net之后的MLP
    46. x = F.relu(self.bn2(self.conv2(x)))
    47. #这里是第三个MLP,此时每个点到了1024维
    48. x = self.bn3(self.conv3(x))
    49. #maxpooing
    50. x = torch.max(x, 2, keepdim=True)[0]
    51. x = x.view(-1, 1024)
    52. if self.global_feat:
    53. return x, trans, trans_feat
    54. else:
    55. x = x.view(-1, 1024, 1).repeat(1, 1, N)
    56. return torch.cat([x, pointfeat], 1), trans, trans_feat

    1.4 训练代码

    让我们回到主程序,在完成model的载入后,我们需要做一些基本的设置,就可以开始训练环节了,包括GPU模式确认,中间结果存储设置,优化器选择(Adam或SGD),设置调整学习率的机制,并存储在scheduler。然后设置存储分类结果的变量,以显示每一轮的分类精度:

    1. #set device model
    2. if not args.use_cpu:
    3. classifier = classifier.cuda()
    4. criterion = criterion.cuda()
    5. try:
    6. checkpoint = torch.load(str(exp_dir) + '/checkpoints/best_model.pth')
    7. start_epoch = checkpoint['epoch']
    8. classifier.load_state_dict(checkpoint['model_state_dict'])
    9. log_string('Use pretrain model')
    10. except:
    11. log_string('No existing model, starting training from scratch...')
    12. start_epoch = 0
    13. if args.optimizer == 'Adam':
    14. optimizer = torch.optim.Adam(
    15. classifier.parameters(),
    16. lr=args.learning_rate,
    17. betas=(0.9, 0.999),
    18. eps=1e-08,
    19. weight_decay=args.decay_rate
    20. )
    21. else:
    22. optimizer = torch.optim.SGD(classifier.parameters(), lr=0.01, momentum=0.9)
    23. scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=20, gamma=0.7)
    24. global_epoch = 0
    25. global_step = 0
    26. best_instance_acc = 0.0
    27. best_class_acc = 0.0

    接下来,就是正式的训练代码:

    1. #开始扫描数据,这里的start_epoch记录的是之前训练的次数,首次训练为0
    2. for epoch in range(start_epoch, args.epoch):
    3. log_string('Epoch %d (%d/%s):' % (global_epoch + 1, epoch + 1, args.epoch))
    4. mean_correct = []
    5. #model开启训练模式
    6. classifier = classifier.train()
    7. """在scheduler的step_size表示scheduler.step()每调用step_size次,对应的学习率就会按
    8. 照策略调整一次。所以如果scheduler.step()是放在mini-batch里面,那么step_size指的
    9. 是经过这么多次迭代,学习率改变一次。"""
    10. scheduler.step()
    11. for batch_id, (points, target) in tqdm(enumerate(trainDataLoader, 0),
    12. total=len(trainDataLoader), smoothing=0.9):
    13. #这里的points,target对应ModelNetDataLoader的_get_item方法,获取的是point_set和
    14. #label[0]
    15. #梯度归0
    16. optimizer.zero_grad()
    17. #归一化点云尺度
    18. points = points.data.numpy()
    19. points = provider.random_point_dropout(points)
    20. points[:, :, 0:3] = provider.random_scale_point_cloud(points[:, :, 0:3])
    21. points[:, :, 0:3] = provider.shift_point_cloud(points[:, :, 0:3])
    22. points = torch.Tensor(points)
    23. points = points.transpose(2, 1)
    24. if not args.use_cpu:
    25. points, target = points.cuda(), target.cuda()
    26. #点云放入模型开始训练
    27. pred, trans_feat = classifier(points)
    28. #求loss
    29. loss = criterion(pred, target.long(), trans_feat)
    30. #预测分类结果
    31. pred_choice = pred.data.max(1)[1]
    32. #计算正确率
    33. correct = pred_choice.eq(target.long().data).cpu().sum()
    34. mean_correct.append(correct.item() / float(points.size()[0]))
    35. #梯度反传
    36. loss.backward()
    37. #更新学习率
    38. optimizer.step()
    39. global_step += 1
    40. #得到训练数据集的准确率
    41. train_instance_acc = np.mean(mean_correct)
    42. log_string('Train Instance Accuracy: %f' % train_instance_acc)
    43. with torch.no_grad():
    44. #使用模型来对测试数据进行测试。
    45. instance_acc, class_acc = test(classifier.eval(), testDataLoader,
    46. num_class=num_class)
    47. #赋值best_instance_acc
    48. if (instance_acc >= best_instance_acc):
    49. best_instance_acc = instance_acc
    50. best_epoch = epoch + 1
    51. if (class_acc >= best_class_acc):
    52. best_class_acc = class_acc
    53. log_string('Test Instance Accuracy: %f, Class Accuracy: %f' % (instance_acc,
    54. class_acc))
    55. log_string('Best Instance Accuracy: %f, Class Accuracy: %f' %
    56. (best_instance_acc, best_class_acc))
    57. #如果更新后,得到的精度高于之前的模型,存储,否则,不存储。
    58. if (instance_acc >= best_instance_acc):
    59. logger.info('Save model...')
    60. savepath = str(checkpoints_dir) + '/best_model.pth'
    61. log_string('Saving at %s' % savepath)
    62. state = {
    63. 'epoch': best_epoch,
    64. 'instance_acc': instance_acc,
    65. 'class_acc': class_acc,
    66. 'model_state_dict': classifier.state_dict(),
    67. 'optimizer_state_dict': optimizer.state_dict(),
    68. }
    69. torch.save(state, savepath)
    70. global_epoch += 1
    71. logger.info('End of training...')

    2. 实验结果

    这里,我们设置epoch=100,并且直接贴出结果:

    epoch 10:

    Test Instance Accuracy: 0.762298, Class Accuracy: 0.693224
    Best Instance Accuracy: 0.762298, Class Accuracy: 0.693224

    epoch 50:

    Test Instance Accuracy: 0.876214, Class Accuracy: 0.817101
    Best Instance Accuracy: 0.878074, Class Accuracy: 0.822420

    epoch 100:

    Test Instance Accuracy: 0.887621, Class Accuracy: 0.833796
    Best Instance Accuracy: 0.890129, Class Accuracy: 0.850198

    可以看到,epoch 100后的分类准确率为0.89。

    Reference

    [1] Qi C, et al. Pointnet: Deep learning on point sets for 3d classification and segmentation[C]. Proceedings of the IEEE conference on computer vision and pattern recognition. 2017: 652-660.

  • 相关阅读:
    PCF8591学习笔记
    Linux(Centos6)搭建ElasticSearch(图文教程)
    结构型模式
    【模电实验】【精简版】【验证性实验——两级阻容耦合负反馈放大器实验】
    2022 蔚来杯 牛客多校 后缀自动机(SAM) 马拉车(Manacher)
    【TES745D】青翼自研基于复旦微的FMQL45T900全国产化ARM核心模块(100%国产化)
    CesiumJS 下载太慢了
    在Saas系统下多租户零脚本分表分库读写分离解决方案
    Oracle CPU使用率过高问题处理
    WordPress媒体文件夹v5.1.2插件WP Media folde
  • 原文地址:https://blog.csdn.net/aliexken/article/details/126765973