本博文先介绍S3DIS的基本情况和路径结构,从而对该数据集有一个整体的了解,然后会在第三节中了解一下pointnet++中如何对该数据集转换,以及转换的原因,同时在第三节中会介绍pointnet++中如何构建语义分割数据集,最后会接受如何对S3DIS数据集进行预测。
1.S3DIS数据集简介
S3DIS是室内的大型数据集,共有6个区域,13个类别,第一反应是只有6个区域吗,这么少,训练2D目标检测的数据随随便便就上千了。虽说只有6个区域,但是每个区域划分了很多的场景呀,这6个区域一共拆分了271个数据,每个数据都有上百万个点,而最终送进去网络中训练的也不是这271个数据,而是从这217个区域中划分的更小的区域,只要你想,训练集的数据能多大就有多大(图片参考自链接)。
先区分一下Area和room的概念,Area就是指这6个区域,room是指6个区域划分出来的271个房间,这些房间可能是办公室,会议室,走廊等。
然后S3DIS这个数据集是包含RGB颜色信息的,加上XYZ坐标信息,一共就6维。
数据集的结构在将下一节进行介绍。
2.数据路径结构
以PointNet++中的S3DIS为例,s3dis/Stanford3dDataset_v1.2_Aligned_Version路径下有6个区域,如下所示:
每一个Area下面是一些区域,比如Area_1下有两个会议室(ConferenceRoom),八个走廊(hallway),若干个办公室(office)等。
将会议室1展开,发现有一个Annotations文件夹,一个conferenceRoom_1.txt文件,其中Annotations里保存的是会议室1中所有物品的xyzrgb坐标,均为n行6列的数据,而文件名则表示该物品的标签。比如chair_1.txt中共6729行6列,表示该物品由6729个点组成,且包含xyzrgb六个参数,标签为chair,可视化结果如下所示。
下图为Annotations展开后的路径结构。
下图为chair_1.txt里的内容以及使用CloudCompare可视化的结果
而另外一个ConferenceRoom_1.txt保存的是这个会议室中所有的点,有1136677个点,可见一个会议室的房间的点都这么多,那么一个Area的点不得上千万呀,可视化如下所示,当然,放大去看也能找到刚刚可视化的那张椅子。
3.collect_indoor3d_data数据转换
在pointnet++中,往往需要对原始的S3DIS进行转换,那么为什么需要转换?因为S3DIS数据集只是存储一些点,并没有标签(标签是存储在文件名上的),而collect_indoor3d_data脚本所做的事情就是将每一个Area下的每一个场景的点和标签进行合并,并且保存为.npy格式,加速读取的速度。转换后的数据集如下所示,也可以参考我的另一篇博文如何使用S3DIS训练pointent++语义分割模型,Win10系统下复现Pointnet++(pytorch)_吃鱼不卡次的博客-CSDN博客:
可以使用numpy.load()打开,查看其形状,可以看到一共有七列,最后一列表示的是类别,如下所示:
4.构建数据集S3DISDataset
训练的时候使用的S3DISDataset来构建数据集(如下所示),测试的时候使ScannetDatasetWholeScene来构造数据集,两者是有区别的,测试的会放在下一节来介绍。
from data_utils.S3DISDataLoader import S3DISDataset
print("start loading training data ...")
TRAIN_DATASET = S3DISDataset(split='train', data_root=root, num_point=NUM_POINT, test_area=args.test_area, block_size=1.0, sample_rate=1.0, transform=None)
print("start loading test data ...")
TEST_DATASET = S3DISDataset(split='test', data_root=root, num_point=NUM_POINT, test_area=args.test_area, block_size=1.0, sample_rate=1.0, transform=None)
众所周知,dataset一定要包括三个函数,初始化函数__init_(),依次返回数据的函数__getitem__(),获取数据长度的函数__len__(),下面我以下面这4个.npy文件作为例子,分别来介绍一下这三个函数。
4.1__init__()函数
首先,先看一下函数的参数:
split表示构建数据集的类型,有train和test两种,分别表示训练集和测试集;如果是测试集,那么会使用test_area参数,比如test_area=5这个参数将指定Area5作为测试集,第一节已经介绍过了,一共六个区域,每个区域的点的数量很庞大,选择其中一个区域作为测试集(验证集)也是合理的,而且也可以当成是5:1划分数据集了。
data_root表示数据的路径,调用的就是第二节中转换而来得到的全是.npy文件的路径,即s3dis\Stanford3dDataset_v1.2_Aligned_Version。num_point表示经过预处理后输入网络的点的数量,比如默认设置为4096,则表示输入网络的点数为4096。sample_rate是用来控制用作训练数据的数量的。block_size为随机选择区域的宽高。
一口气说完那么多,是否觉得很迷糊,没关系,后面都会详细去讲的。
其次,看一下后面的代码:
rooms比较简单,存储的是npy的文件名,该例子中为['Area_1_conferenceRoom_1.npy', 'Area_1_conferenceRoom_2.npy', 'Area_1_copyRoom_1.npy', 'Area_5_office_12.npy'];
rooms_split为划分训练集和验证集,如果该数据集为训练集,将把包含Area_5的数据排除在外,则rooms_split=['Area_1_conferenceRoom_1.npy', 'Area_1_conferenceRoom_2.npy', 'Area_1_copyRoom_1.npy'];如果该数据集为测试集,则rooms_split=['Area_5_office_12.npy']。
def __init__(self, split='train', data_root='trainval_fullarea', num_point=4096, test_area=5, block_size=1.0, sample_rate=1.0, transform=None):
super().__init__()
self.num_point = num_point
self.block_size = block_size
self.transform = transform
rooms = sorted(os.listdir(data_root))
rooms = [room for room in rooms if 'Area_' in room]
if split == 'train':
rooms_split = [room for room in rooms if not 'Area_{}'.format(test_area) in room]
else:
rooms_split = [room for room in rooms if 'Area_{}'.format(test_area) in room]
这几个参数主要是保存各项参数的,后面再说。
self.room_points, self.room_labels = [], []
self.room_coord_min, self.room_coord_max = [], []
num_point_all = []
labelweights = np.zeros(13)
再次,这段代码是逐个读取rooms_split中的数据,通过points保存每个文件的xyzrgb共n行6列信息,labels保存对应点的标签信息(n行1列);通过np.histogram()来统计每个类别出现的次数,以rooms_split[0]为例子,返回的tmp为array([213074, 190384, 354422, 61528, 0, 0, 41345, 31049,77761, 0, 20437, 86833, 59784], dtype=int64),代表着rooms_split[0]的这个点云,统计的13个类别的数量,比如,第一个类别出现了213074个,这些所有值相加就是这个点云的点的数量,那么为什么要统计这个呢,是为了后面做损失的时候设置一个类别权重,遍历完所有数据之后labelweights += tmp 中的labelweights就会统计完所有数据的所有类别数量。
coord_min, coord_max分别代表的是xyz中最小的点以及最大的点,举个例子,如下所示:points为一个3行3列的数组,np.amin()得到的值[1,2,-1]是每一列中的最小值,然后说一下对于axis参数的理解:axis=0是指第零个维度发生变化,即由3行3列变为1行3列,那么也就是说在每一列中找最小值。
self.room_points是一个列表,列表中存储的是每个点云的点,列表的大小即为点云的数量。self.room_labels也是一个列表,存储的是self.room_points中每个点云下的点的类别。同理,self.room_coord_min和self.room_coord_max也是列表,存储的是每个点云的xyz的最小值以及最大值。num_point_all存储的是每个点云的数量。
遍历完所有点云后可以看到,self.room_points(如下图所示)列表的长度为3,存储的是3个点云的xyzrgb信息,其中第3个点云的形状为(510949,6)表示的是该点云有510949个点,每个点有6个属性。self.room_labels每个元素的shape为(510949,1),self.room_coord_min和self.room_coord_max每个元素的shape为(1,3),num_point_all每个元素的shape为(1,)。
for room_name in tqdm(rooms_split, total=len(rooms_split)):
room_path = os.path.join(data_root, room_name)
room_data = np.load(room_path) # xyzrgbl, N*7
points, labels = room_data[:, 0:6], room_data[:, 6] # xyzrgb, N*6; l, N
tmp, _ = np.histogram(labels, range(14))
labelweights += tmp
coord_min, coord_max = np.amin(points, axis=0)[:3], np.amax(points, axis=0)[:3]
self.room_points.append(points), self.room_labels.append(labels)
self.room_coord_min.append(coord_min), self.room_coord_max.append(coord_max)
num_point_all.append(labels.size)
接着,下面是对类别权重的处理,遍历完所有点云后,labelweights存储的是各个类别的点的数量,设置权重一般是把点数多的类别权重设置小一点,因为点数多说明这个类别相较于点数少的类别更容易学习,那么最简单的方法就是取点数的倒数,但是这个过于简单粗暴,那么我们看看pointnet++是如何设置类别权重的(这段我直接问ChatGPT了,解释得很清楚,也能理解):
1.labelweights = labelweights.astype(np.float32):将 labelweights 数组的数据类型转换为 np.float32,以便后续的数值计算。
2.labelweights = labelweights / np.sum(labelweights):将 labelweights 数组中的每个元素除以数组中所有元素的和,以获得每个类别的相对权重。这样做可以确保权重的总和为 1。
3.np.power(np.amax(labelweights) / labelweights, 1 / 3.0):计算每个类别权重相对于最大权重的比例的三分之一次方。这个操作的目的是将较大的权重值进行缩放,以确保它们不会在损失计算中产生过大的影响。这有助于平衡损失在各个类别之间的影响。
labelweights = labelweights.astype(np.float32)
labelweights = labelweights / np.sum(labelweights)
self.labelweights = np.power(np.amax(labelweights) / labelweights, 1 / 3.0)
print(self.labelweights)
最后,最重要的一个参数登场了,room_idxs,这段代码其实就是分配一下每一个点云要采多少个区域输入到网络里面去训练。
sample_prob的值为array([0.35713406, 0.48232172, 0.16054422]),代表这每个点云中点的数量占总点数的比例,然后会按照这个比例来进行分配。
num_iter其实就是粗略计算了一下总共要采多少个区域,np.sum(num_point_all) * sample_rate可以理解为点云下采样后还剩多少点,然后再除以num_point,意思是剩下的点可以被分成多少块区域,每个区域num_point个点,其实我觉得完全可以设定一个参数,就训练1000张图片好像也不是不行。
room_idxs中保存的是每个点云要采样的次数,举个例子说明一下(如下所示):遍历完所有点云之后,共得到27个0,表示第一个点云我要随机裁剪27个区域,第二个点云要随机裁剪成37个区域,第三个点云同理,那么训练集一共有76个数据送去训练。那么27,37还有12是怎么得到的呢,是通过int(round(sample_prob[index] * num_iter))得到的,其实也就是按照点云的点数占总点数的比例来得到每个点云所要选择的区域的。
sample_prob = num_point_all / np.sum(num_point_all)
num_iter = int(np.sum(num_point_all) * sample_rate / num_point)
room_idxs = []
for index in range(len(rooms_split)):
room_idxs.extend([index] * int(round(sample_prob[index] * num_iter)))
self.room_idxs = np.array(room_idxs)
print("Totally {} samples in {} set.".format(len(self.room_idxs), split))
Init()函数这部分的参数实在太多,简单举个例子见图知意吧,首先假设数据集就一共有3个点云,每个点云中的点分别有200、500和300,则一共有1000个点(num_point_all),输入网络的npoint为50,并且设置sample_rate,那么这三个点云我会生成1000*0.5/50=10个点数均为50的数据输入到网络中去训练。
接下来要确定一下这三个点云各自贡献多少数据,如下图所示Area1分配了2个数据的名额,Area2是5个,Area3是3个,也就是按照比例分配,看图就明白了。
4.2__getItem__()函数
__getitem__()函数就是逐个读取__init__中构造的数据,这个函数只干一件事,就是生成可以直接喂到网络里的数据(current_points)和标签(current_labels)。
前面提到的在room1中要裁27个区域作为输入网络的数据,那么idx从0到26都将停留在room1中,按照以下的规则生成27组数据和标签。
while (True):
center = points[np.random.choice(N_points)][:3]
block_min = center - [self.block_size / 2.0, self.block_size / 2.0, 0]
block_max = center + [self.block_size / 2.0, self.block_size / 2.0, 0]
point_idxs = np.where((points[:, 0] >= block_min[0]) & (points[:, 0] <= block_max[0]) & (points[:, 1] >= block_min[1]) & (points[:, 1] <= block_max[1]))[0]
if point_idxs.size > 1024:
Break
这段代码是为了随机找到一块宽高均为block_size大小的区域,并且要求该区域的点数要大于1024,否则就得重新选择新的区域。那么为什么要这样设置呢?是因为随机选取中心点的时候有可能找到角落上的点,第一会导致选取的这块区域不是block_size大小的正方形区域,第二是如果该区域的点太少了,即便后面可以重复取点到4096,但是网络获取到的有效点就少了,影响网络的训练。
如下图可以比较清晰的看出来是如何根据中心点划分block的:(1)首先左图a是一个点云Room,右图b是点云Room的俯视图;(2)分别选取两个中心点A和B,根据block_size在俯视图b中确定裁剪的区域,然后投影到点云Room中,如左图a所示;(3)由右图b可知,裁剪的区域A所示为正常采点的情况,采集的点是左图a中立方体A里面包含的点;B是当中心点在边缘的时候,若左图a立方体B中包含的点少于1024,则需要重新选取中心点,直到满足大于1024个点的要求。
(a)Room (b)Room俯视图
if point_idxs.size >= self.num_point:
selected_point_idxs = np.random.choice(point_idxs, self.num_point, replace=False)
else:
selected_point_idxs = np.random.choice(point_idxs, self.num_point, replace=True)
当确定好需要裁剪的区域时,则对区域内的点进行随机采样到num_point个点,即4096个点。
最后是对这4096个点的xyz值进行归一化,去中心化操作,对rgb进行归一化操作,因此,数据的特征也从xyzrgb的三维变成了xc yc zc nx ny nz nr ng nb的九维了,其中xc yc zc指的是去中心化后的xyz坐标,nx ny nz指的是归一化后的xyz坐标 nr ng nb指的是归一化后的rgb坐标。
selected_points = points[selected_point_idxs, :] # num_point * 6
current_points = np.zeros((self.num_point, 9)) # num_point * 9
current_points[:, 6] = selected_points[:, 0] / self.room_coord_max[room_idx][0]
current_points[:, 7] = selected_points[:, 1] / self.room_coord_max[room_idx][1]
current_points[:, 8] = selected_points[:, 2] / self.room_coord_max[room_idx][2]
selected_points[:, 0] = selected_points[:, 0] - center[0]
selected_points[:, 1] = selected_points[:, 1] - center[1]
selected_points[:, 3:6] /= 255.0
current_points[:, 0:6] = selected_points
current_labels = labels[selected_point_idxs]
如下图所示,输入到网络中的数据就是9维的,最后一维是标签(我自己加上去的,可以不用管)。
4.3__len__()函数
__len()__一般是指数据集有多少数据。
5.总结
内容大概就这么多吧,其实了解了S3DIS的结构就行了,知道Area由room构成,然后在room中按照比例随机生成数据输入到网络中。然后具体每个room生成多少数据以及怎么生成数据,只要看懂4.2中的两张图就可以了。我个人认为,3D点云数据集必须要好好去了解数据集是怎么构成的,因为点云数据集真的太五花八门了,每个数据集的结构都不一样(3D目标检测KITTI这些好像更复杂),了解数据集是怎么构成后,去看dataset的代码就不会那么容易乱了。
关于S3DIS数据集还有什么疑问或者还有什么需要补充的可以在评论区留言,大家一起学习一起进步。