作为一个小白,笔者认为,从实践角度去深入一个深度学习领域(比如3D目标检测),不可忽视两件最为基础的事情:(1)怎样处理数据集;(2)怎样评估实验结果。这篇博客分析cvpr2020论文SA-SSD: Structure Aware Single-stage 3D Object Detection from Point Cloud
的开源代码中的数据预处理,模型训练和结果评估这几块基础代码。
总结自己分析代码的两个小技巧:(1)从顶到下理解代码;(2)没有必要读懂全部的代码,只需要读懂需要读懂的部分即可。
从代码的ReadMe获知数据处理使用tools/create_data.py
文件。这个文件主要做三件事。
(1)调用函数create_kitti_info_file
获取数据集中点云图像路径;
(2)调用函数create_reduced_point_cloud
获取相机视场内的点云;
(3)调用函数create_groundtruth_database
生成3D目标检测真值;
这一节分析函数create_kitti_info_file
。数据集分成三个数据子集,训练集(Train),验证集(Validation),和测试集(Test)。测试集没有真值,测试集的结果需要提交到KITTI上,得到3D目标各个类别检测精度。在KITTI上提交结果需要先注册KITTI账号,用学校或企业邮箱注册即可。
第一步:获取各个子集index
从SA-SSD开源代码中下载imagesets.tar.gz
,里面包含train.txt
,val.txt
,test.txt
。这些文本中存放各个子数据集的index。函数_read_imageset_file
会读取它们的index,并保存成list
数据结构:
train_img_ids = _read_imageset_file("./data/ImageSets/train.txt")
val_img_ids = _read_imageset_file("./data/ImageSets/val.txt")
test_img_ids = _read_imageset_file("./data/ImageSets/test.txt")
第二步:获取各个子集中点云和图像的路径
对各个子数据集调用函数get_kitti_image_info
,以获取点云和图像的路径。函数_calculate_num_points_in_gt
用来数相机视场范围内LiDAR点云的个数。点云总数会存放在字典型变量kitti_infos_train
中。结果将存放在pkl
类型文件。
对训练数据而言:
kitti_infos_train = kitti.get_kitti_image_info(
data_path,
training=True,
velodyne=True,
calib=True,
image_ids=train_img_ids,
relative_path=relative_path)
_calculate_num_points_in_gt(data_path, kitti_infos_train, relative_path)
filename = save_path / 'kitti_infos_train.pkl'
print(f"Kitti info train file is saved to {filename}")
with open(filename, 'wb') as f:
pickle.dump(kitti_infos_train, f)
上述这个过程适用于验证集和测试集的处理。把path换了就行。
简要分析函数get_kitti_image_info
,它的输出image_info
是一个字典型变量,包含'image_idx'
,pointcloud_num_features
,velodyne_path
,img_path
,img_shape
,annos
。image_info
还包含标定的参数,比如相机内参数,calib/P0
到calib/P4
,雷达相机外参数,calib/R0_rect
和calib/Tr_velo_to_cam
。
我重点交代annos
,它也是一个字典型变量,跟目标检测标注相关。生成它的代码如下所示:
if label_info:
# 找到目标检测标签label的路径
label_path = get_label_path(idx, path, training, relative_path)
if relative_path:
# 如果是相对路径就加上前缀,得到绝对路径
label_path = str(root_path / label_path)
# 读取目标检测标签
annotations = get_label_anno(label_path)
if annotations is not None:
image_info['annos'] = annotations
# 根据kitti官方指标(比如遮挡度),给目标检测标签添加难易度评价
# annos["difficulty"] = 0(Easy), 1(Mid),2(Hard)
add_difficulty_to_annos(image_info)
有必要了解函数get_label_anno
,来看一下哈:
def get_label_anno(label_path):
annotations = {}
annotations.update({
'name': [],
'truncated': [],
'occluded': [],
'alpha': [],
'bbox': [],
'dimensions': [],
'location': [],
'rotation_y': []
})
with open(label_path, 'r') as f:
lines = f.readlines()
# if len(lines) == 0 or len(lines[0]) < 15:
# content = []
# else:
content = [line.strip().split(' ') for line in lines]
num_objects = len([x[0] for x in content if x[0] != 'DontCare'])
annotations['name'] = np.array([x[0] for x in content])
num_gt = len(annotations['name'])
annotations['truncated'] = np.array([float(x[1]) for x in content])
annotations['occluded'] = np.array([int(float(x[2])) for x in content])
annotations['alpha'] = np.array([float(x[3]) for x in content])
annotations['bbox'] = np.array(
[[float(info) for info in x[4:8]] for x in content]).reshape(-1, 4)
# dimensions will convert hwl format to standard lhw(camera) format.
annotations['dimensions'] = np.array(
[[float(info) for info in x[8:11]] for x in content]).reshape(
-1, 3)[:, [2, 0, 1]]
annotations['location'] = np.array(
[[float(info) for info in x[11:14]] for x in content]).reshape(-1, 3)
annotations['rotation_y'] = np.array(
[float(x[14]) for x in content]).reshape(-1)
if len(content) != 0 and len(content[0]) == 16: # have score
annotations['score'] = np.array([float(x[15]) for x in content])
else:
annotations['score'] = np.zeros((annotations['bbox'].shape[0], ))
index = list(range(num_objects)) + [-1] * (num_gt - num_objects)
annotations['index'] = np.array(index, dtype=np.int32)
annotations['group_ids'] = np.arange(num_gt, dtype=np.int32)
return annotations
在annos
中,truncated
和occluded
用于衡量该3d目标被遮挡的程度,用于给该3d目标被检测到的难易程度打分。bbox
和dimensions
都表示3d目标的长宽高,它们的区别见代码中的注释。location
表示3d框中心点的位置。rotation_y
表示3d框中Yaw角度。score
表示目标类别置信度。num_objects
表示当前点云中有多少3d目标。name
指3d目标类别。index
和group_ids
指该3d目标的索引和类索引。这些变量的英文解释如下所示:
函数_calculate_num_points_in_gt
会根据雷达外参数把点云投在相机坐标系下,然后滤除相机视场外的点,计算剩下点云的个数,把结果更新到annos["num_points_in_gt"]
中。
KITTI官方给出对Easy,Medium,Hard的定量解释。
Easy: Min. bounding box height: 40 Px, Max. occlusion level: Fully visible, Max. truncation: 15 %
Moderate: Min. bounding box height: 25 Px, Max. occlusion level: Partly occluded, Max. truncation: 30 %
Hard: Min. bounding box height: 25 Px, Max. occlusion level: Difficult to see, Max. truncation: 50 %
这一节分析函数create_reduced_point_cloud
。
第一步:读各个子数据集在2.1节整理的pkl
类型文件
if train_info_path is None:
train_info_path = pathlib.Path(data_path) / 'kitti_infos_train.pkl'
if val_info_path is None:
val_info_path = pathlib.Path(data_path) / 'kitti_infos_val.pkl'
if test_info_path is None:
test_info_path = pathlib.Path(data_path) / 'kitti_infos_test.pkl'
第二步:获取相机视场范围内的点云
# 视场范围内的点云保存在velodyne_reduced中
_create_reduced_point_cloud(data_path, train_info_path, save_path)
_create_reduced_point_cloud(data_path, val_info_path, save_path)
_create_reduced_point_cloud(data_path, test_info_path, save_path)
简要分析函数create_groundtruth_database
。这一段代码有点杂乱。大致是每一类别的3d框都收集起来。把3D框真值存在pkl
文件中。它的具体用处看后续代码中怎么调用吧。
学习3D框标注信息以及如何处理它。
SA-SSD
代码实在mmdetection
平台上开发的。所以它无论是训练还是做预测,都会按照mmdetection
的一套流程。关于mmdetection
各种api的介绍可以参考这篇非常不错的博客。也可以参考mmdetection官方文档。这篇知乎帖子写的也很不错。
如果要训练一个网络,会执行下面代码:
python3 train.py ../configs/car_cfg.py
其中car_cfg.py
是配置文件,用于保存模型超参数,训练超参数,和测试超参数,以及学习策略配置等等。
在train.py
文件中,用下述代码生成训练数据集:
# 生成训练数据集
train_dataset = get_dataset(cfg.data.train)
# 开始训练
# mmdetection的一行代码就训练,然而这种简洁的操作多少有些惊艳
# 后来发现,损失函数(smooth l1),优化器(SGD),学习率,训练批次等都在car_cfg.py定义好啦
train_detector(
model,
train_dataset,
cfg,
distributed=distributed,
validate=args.validate,
logger=logger)
看看cfg
内容(一堆参数):
# 虽然参数很多,但是看到超参数的名字不难猜到它的意思
# dataset settings
# model training and testing settings
train_cfg = dict(
rpn=dict(
assigner=dict(
pos_iou_thr=0.6,
neg_iou_thr=0.45,
min_pos_iou=0.45, # this one is to limit the force assignment
ignore_iof_thr=-1,
similarity_fn ='NearestIouSimilarity'
),
nms=dict(
nms_across_levels=False,
nms_pre=2000,
nms_post=2000,
nms_thr=0.7,
min_bbox_size=0
),
allowed_border=0,
pos_weight=-1,
smoothl1_beta=1 / 9.0,
debug=False),
extra=dict(
assigner=dict(
pos_iou_thr=0.7,
neg_iou_thr=0.7,
min_pos_iou=0.7,
ignore_iof_thr=-1,
similarity_fn ='RotateIou3dSimilarity'
)
)
)
dataset_type = 'KittiLiDAR'
data_root = '/home/billyhe/data/KITTI/'
img_norm_cfg = dict(
mean=[123.675, 116.28, 103.53], std=[58.395, 57.12, 57.375], to_rgb=True)
data = dict(
imgs_per_gpu=2,
workers_per_gpu=4,
train=dict(
type=dataset_type,
root=data_root + 'training/',
ann_file=data_root + 'ImageSets/train.txt',
img_prefix=None,
img_scale=(1242, 375),
img_norm_cfg=img_norm_cfg,
size_divisor=32,
flip_ratio=0.5,
with_mask=False,
with_label=True,
with_point=True,
class_names = ['Car', 'Van'],
augmentor=dict(
type='PointAugmentor',
root_path=data_root,
info_path=data_root + 'kitti_dbinfos_train.pkl',
sample_classes=['Car'],
min_num_points=5,
sample_max_num=15,
removed_difficulties=[-1],
global_rot_range=[-0.78539816, 0.78539816],
gt_rot_range=[-0.78539816, 0.78539816],
center_noise_std=[1., 1., .5],
scale_range=[0.95, 1.05]
),
generator=dict(
type='VoxelGenerator',
voxel_size=[0.05, 0.05, 0.1],
point_cloud_range=[0, -40., -3., 70.4, 40., 1.],
max_num_points=5,
max_voxels=20000
),
anchor_generator=dict(
type='AnchorGeneratorStride',
sizes=[1.6, 3.9, 1.56],
anchor_strides=[0.4, 0.4, 1.0],
anchor_offsets=[0.2, -39.8, -1.78],
rotations=[0, 1.57],
),
anchor_area_threshold=1,
out_size_factor=8,
test_mode=False),
# 做验证的超参数和训练超参数是一样的,就不放出来了。
# 在val中,test_mode=True
# val=dict(...)
)
# optimizer
optimizer = dict(type='SGD', lr=0.01, momentum=0.9, weight_decay=0.001)
optimizer_config = dict(grad_clip=dict(max_norm=10, norm_type=2))
# learning policy
lr_config = dict(
policy='cosine',
warmup='linear',
warmup_iters=500,
warmup_ratio=1.0 / 3,
)
checkpoint_config = dict(interval=5)
log_config = dict(
interval=20,
hooks=[
dict(type='TextLoggerHook'),
])
total_epochs = 50
dist_params = dict(backend='nccl')
log_level = 'INFO'
work_dir = '../saved_model_vehicle'
load_from = None
resume_from = None
workflow = [('train', 1)]
然后瞧瞧函数get_dataset
,它的核心操作是调用函数obj_from_dict
(大概是根据输入需求写data_info
,然后从datasets
读出dset
,添加至dsets
。dsets
是输出的训练数据):
dsets = []
for i in range(num_dset):
data_info = copy.deepcopy(data_cfg)
data_info['ann_file'] = ann_files[i]
data_info['proposal_file'] = proposal_files[i]
data_info['img_prefix'] = img_prefixes[i]
if generator is not None:
data_info['generator'] = generator
if anchor_generator is not None:
data_info['anchor_generator'] = anchor_generator
if augmentor is not None:
data_info['augmentor'] = augmentor
if target_encoder is not None:
data_info['target_encoder'] = target_encoder
# 核心操作
dset = obj_from_dict(data_info, datasets)
dsets.append(dset)
if len(dsets) > 1:
dset = ConcatDataset(dsets)
else:
dset = dsets[0]
return dset
而函数train_detector
是一套模板化流程,其中调用了torch
的DataLoader
。更为具体的分析可以参考mmdetection说明博客。
作为小白,处理一个完整的数据集,不仅仅需要对数据做预处理,还需要做结果评估(Evaluation),即计算预测值和真值间的误差。3D目标检测的误差计算稍微复杂。我们看看SA-SSD
是怎样做处理的。
怎样衡量网络预测的3D框和对应真值3D框之间的差异呢?对于KITTI数据集来说,拿车类目标做个例子,如果预测3D框和真值3D框之间重叠的部分占真值3D框的70%以上(70%是官方规定的),那么就可以认为预测3D框是准确的。如果10个预测结果中,有7个是准确的,那么车类目标识别率是70%。3D目标识别率简记为 A P AP AP,或者 3 D A P 3D AP 3DAP,或者 A P 3 D AP_{3D} AP3D。
使用IoU(Intersection-over-Union)表示预测3D框和真值3D框之间重叠的部分占真值3D框的比重。
每一种类别,KITTI官方规定的百分百阈值都不一样,可以参考下面原话:
For cars we require an 3D bounding box overlap of 70%, while for pedestrians and cyclists we require a 3D bounding box overlap of 50%.
把不同类别的目标识别结果平均起来,可以得到平均目标识别结果 m A P mAP mAP。
对于同一种类别,不断调整百分百阈值(称之为召回率,Recall),可以得到不同阈值下的 A P AP AP值(称之为精度,Precision),进而会形成一条关于阈值百分比和 A P AP AP的曲线,即召回率精度曲线(Recall-Precision Curve)。曲线覆盖面积将反应算法实际性能。曲线覆盖面积越趋近于1,算法性能越好。
除了使用 3 D A P 3D AP 3DAP,还会检测2D目标的精度 2 D A P 2D AP 2DAP,还会检测在 B E V BEV BEV视图下的目标精度 B E V A P BEV AP BEVAP。这些 A P AP AP的定义都差不多。
从代码的ReadMe获知结果评估使用tools/test.py
文件。需要如下操作:
python3 test.py ../configs/car_cfg.py ../saved_model_vehicle/epoch_50.pth
其中car_cfg.py
是配置文件,用于保存模型超参数,训练超参数,和测试超参数,以及学习策略配置等等。pth
文件是模型训练好的参数。
评估流程代码如下。把Train那一块流程搞懂,这一块流程也很相似,比如常见函数get_dataset
,build_detector
(加载SA-SSD
网络,具体代码会在下一篇博客分析)。函数get_official_eval_result
用来评估目标检测结果,输出 3 D A P 3D AP 3DAP, 2 D A P 2D AP 2DAP,和 B E V A P BEV AP BEVAP。
dataset = utils.get_dataset(cfg.data.val)
class_names = cfg.data.val.class_names
if args.gpus == 1:
model = build_detector(
cfg.model, train_cfg=None, test_cfg=cfg.test_cfg)
load_checkpoint(model, args.checkpoint)
model = MMDataParallel(model, device_ids=[0])
data_loader = build_dataloader(
dataset,
1,
cfg.data.workers_per_gpu,
num_gpus=1,
#collate_fn= cfg.data.collate_fn,
shuffle=False,
dist=False)
# 一口气输出全部测试集的预测结果
outputs = single_test(model, data_loader, args.out, class_names)
else:
NotImplementedError
# kitti evaluation
gt_annos = kitti.get_label_annos(dataset.label_prefix, dataset.sample_ids)
# 计算class_names类别下2D AP,3D Ap和BEV AP
result = get_official_eval_result(gt_annos, outputs, current_classes=class_names)
因为是小白,不懂3D目标检测,从白天看到黑夜,把整体框架看明白了。初次接触mmdetection
有点难受。但是弄懂了,就觉得这个框架对调参数和搭建网络都非常友好。mmdetection
是面向目标检测的代码集成库,还需要深入学习。在下一篇博客中,我将分析SA-SSD
的网络细节。有时间我会分析一下mmdetection
。不知道时间是否充裕,就不立flag啦。