From 9b67ef53098f6e4398876f0a2f1d786f474bff8c Mon Sep 17 00:00:00 2001 From: Kaipeng Deng Date: Thu, 5 Dec 2019 13:15:27 +0800 Subject: [PATCH] Add PointRCNN model (#3967) * add PointRCNN model by heavengate, FDInSky, tink2123 --- PaddleCV/Paddle3D/PointRCNN/.gitignore | 14 + PaddleCV/Paddle3D/PointRCNN/README.md | 338 +++++ .../Paddle3D/PointRCNN/build_and_install.sh | 7 + PaddleCV/Paddle3D/PointRCNN/cfgs/default.yml | 167 +++ .../PointRCNN/data/KITTI/object/download.sh | 25 + PaddleCV/Paddle3D/PointRCNN/data/__init__.py | 13 + .../Paddle3D/PointRCNN/data/kitti_dataset.py | 77 ++ .../PointRCNN/data/kitti_rcnn_reader.py | 1184 +++++++++++++++++ PaddleCV/Paddle3D/PointRCNN/eval.py | 343 +++++ PaddleCV/Paddle3D/PointRCNN/ext_op | 1 + PaddleCV/Paddle3D/PointRCNN/images/teaser.png | Bin 0 -> 332341 bytes .../Paddle3D/PointRCNN/models/__init__.py | 13 + .../Paddle3D/PointRCNN/models/loss_utils.py | 201 +++ .../Paddle3D/PointRCNN/models/point_rcnn.py | 125 ++ .../PointRCNN/models/pointnet2_modules.py | 197 +++ .../PointRCNN/models/pointnet2_msg.py | 78 ++ PaddleCV/Paddle3D/PointRCNN/models/rcnn.py | 302 +++++ PaddleCV/Paddle3D/PointRCNN/models/rpn.py | 167 +++ PaddleCV/Paddle3D/PointRCNN/requirement.txt | 6 + .../PointRCNN/tools/generate_aug_scene.py | 330 +++++ .../PointRCNN/tools/generate_gt_database.py | 104 ++ .../Paddle3D/PointRCNN/tools/kitti_eval.py | 71 + .../tools/kitti_object_eval_python/LICENSE | 21 + .../tools/kitti_object_eval_python/README.md | 32 + .../tools/kitti_object_eval_python/eval.py | 740 +++++++++++ .../kitti_object_eval_python/evaluate.py | 32 + .../kitti_object_eval_python/kitti_common.py | 411 ++++++ .../kitti_object_eval_python/rotate_iou.py | 329 +++++ PaddleCV/Paddle3D/PointRCNN/train.py | 240 ++++ PaddleCV/Paddle3D/PointRCNN/utils/__init__.py | 14 + .../Paddle3D/PointRCNN/utils/box_utils.py | 275 ++++ .../Paddle3D/PointRCNN/utils/calibration.py | 143 ++ PaddleCV/Paddle3D/PointRCNN/utils/config.py | 279 ++++ .../PointRCNN/utils/cyops/__init__.py | 15 + .../PointRCNN/utils/cyops/iou3d_utils.pyx | 195 +++ .../PointRCNN/utils/cyops/kitti_utils.pyx | 346 +++++ .../PointRCNN/utils/cyops/object3d.py | 107 ++ .../PointRCNN/utils/cyops/roipool3d_utils.pyx | 160 +++ .../Paddle3D/PointRCNN/utils/cyops/setup.py | 74 ++ .../Paddle3D/PointRCNN/utils/metric_utils.py | 216 +++ PaddleCV/Paddle3D/PointRCNN/utils/object3d.py | 113 ++ .../Paddle3D/PointRCNN/utils/optimizer.py | 122 ++ .../PointRCNN/utils/proposal_target.py | 369 +++++ .../PointRCNN/utils/proposal_utils.py | 270 ++++ .../PointRCNN/utils/pts_utils/CMakeLists.txt | 6 + .../PointRCNN/utils/pts_utils/pts_utils.cpp | 62 + .../PointRCNN/utils/pts_utils/setup.py | 12 + .../PointRCNN/utils/pts_utils/test.py | 7 + .../Paddle3D/PointRCNN/utils/run_utils.py | 110 ++ .../Paddle3D/PointRCNN/utils/save_utils.py | 132 ++ 50 files changed, 8595 insertions(+) create mode 100644 PaddleCV/Paddle3D/PointRCNN/.gitignore create mode 100644 PaddleCV/Paddle3D/PointRCNN/README.md create mode 100644 PaddleCV/Paddle3D/PointRCNN/build_and_install.sh create mode 100644 PaddleCV/Paddle3D/PointRCNN/cfgs/default.yml create mode 100644 PaddleCV/Paddle3D/PointRCNN/data/KITTI/object/download.sh create mode 100644 PaddleCV/Paddle3D/PointRCNN/data/__init__.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/data/kitti_dataset.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/data/kitti_rcnn_reader.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/eval.py create mode 120000 PaddleCV/Paddle3D/PointRCNN/ext_op create mode 100644 PaddleCV/Paddle3D/PointRCNN/images/teaser.png create mode 100644 PaddleCV/Paddle3D/PointRCNN/models/__init__.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/models/loss_utils.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/models/point_rcnn.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/models/pointnet2_modules.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/models/pointnet2_msg.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/models/rcnn.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/models/rpn.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/requirement.txt create mode 100644 PaddleCV/Paddle3D/PointRCNN/tools/generate_aug_scene.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/tools/generate_gt_database.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/tools/kitti_eval.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/LICENSE create mode 100644 PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/README.md create mode 100644 PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/eval.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/evaluate.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/kitti_common.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/rotate_iou.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/train.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/__init__.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/box_utils.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/calibration.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/config.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/cyops/__init__.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/cyops/iou3d_utils.pyx create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/cyops/kitti_utils.pyx create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/cyops/object3d.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/cyops/roipool3d_utils.pyx create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/cyops/setup.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/metric_utils.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/object3d.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/optimizer.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/proposal_target.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/proposal_utils.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/pts_utils/CMakeLists.txt create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/pts_utils/pts_utils.cpp create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/pts_utils/setup.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/pts_utils/test.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/run_utils.py create mode 100644 PaddleCV/Paddle3D/PointRCNN/utils/save_utils.py diff --git a/PaddleCV/Paddle3D/PointRCNN/.gitignore b/PaddleCV/Paddle3D/PointRCNN/.gitignore new file mode 100644 index 00000000..9ea6e75c --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/.gitignore @@ -0,0 +1,14 @@ +*log* +checkpoints* +build +output +result_dir +pp_pointrcnn* +data/gt_database +utils/pts_utils/dist +utils/pts_utils/build +utils/pts_utils/pts_utils.egg-info +utils/cyops/*.c +utils/cyops/*.so +ext_op/src/*.o +ext_op/src/*.so diff --git a/PaddleCV/Paddle3D/PointRCNN/README.md b/PaddleCV/Paddle3D/PointRCNN/README.md new file mode 100644 index 00000000..0560203b --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/README.md @@ -0,0 +1,338 @@ +# PointRCNN 3D目标检测模型 + +--- +## 内容 + +- [简介](#简介) +- [快速开始](#快速开始) +- [参考文献](#参考文献) +- [版本更新](#版本更新) + +## 简介 + +[PointRCNN](https://arxiv.org/abs/1812.04244) 是 Shaoshuai Shi, Xiaogang Wang, Hongsheng Li. 等人提出的,是第一个仅使用原始点云的2-stage(两阶段)3D目标检测器,第一阶段将 Pointnet++ with MSG(Multi-scale Grouping)作为backbone,直接将原始点云数据分割为前景点和背景点,并利用前景点生成bounding box。第二阶段在标准坐标系中对生成对bounding box进一步筛选和优化。该模型还提出了基于bin的方式,把回归问题转化为分类问题,验证了在三维边界框回归中的有效性。PointRCNN在KITTI数据集上进行评估,论文发布时在KITTI 3D目标检测排行榜上获得了最佳性能。 + +网络结构如下所示: + +

+
+用于点云的目标检测器 PointNet++ +

+ +**注意:** PointRCNN 模型构建依赖于自定义的 C++ 算子,目前仅支持GPU设备在Linux/Unix系统上进行编译,本模型**不能运行在Windows系统或CPU设备上** + + +## 快速开始 + +### 安装 + +**安装 [PaddlePaddle](https://github.com/PaddlePaddle/Paddle):** + +在当前目录下运行样例代码需要 PaddelPaddle Fluid [develop每日版本](https://www.paddlepaddle.org.cn/install/doc/tables#多版本whl包列表-dev-11)或使用PaddlePaddle [develop分支](https://github.com/PaddlePaddle/Paddle/tree/develop)源码编译安装. + +为了使自定义算子与paddle版本兼容,建议您**优先使用源码编译paddle**,源码编译方式请参考[编译安装](https://www.paddlepaddle.org.cn/install/doc/source/ubuntu) + +**安装PointRCNN:** + +1. 下载[PaddlePaddle/models](https://github.com/PaddlePaddle/models)模型库 + +通过如下命令下载Paddle models模型库: + +``` +git clone https://github.com/PaddlePaddle/models +``` + +2. 在`PaddleCV/Paddle3D/PointRCNN`目录下下载[pybind11](https://github.com/pybind/pybind11) + +`pts_utils`依赖`pybind11`编译,须在`PaddleCV/Paddle3D/PointRCNN`目录下下载`pybind11`子库,可使用如下命令下载: + +``` +cd PaddleCV/Paddle3D/PointRCNN +git clone https://github.com/pybind/pybind11 +``` + +3. 编译安装`pts_utils`, `kitti_utils`, `roipool3d_utils`, `iou_utils` 等模块 + +使用如下命令编译安装`pts_utils`, `kitti_utils`, `roipool3d_utils`, `iou_utils` 等模块: +``` +sh build_and_install.sh +``` + +4. 安装python依赖库 + +使用如下命令安装python依赖库: + +``` +pip install -r requirement.txt +``` + +**注意:** KITTI mAP评估工具只能在python 3.6及以上版本中使用,且python3环境中需要安装`scikit-image`,`Numba`,`fire`等子库。 +`requirement.txt`中的`scikit-image`,`Numba`,`fire`即为KITTI mAP评估工具所需依赖库。 + +### 编译自定义OP + +请确认Paddle版本为PaddelPaddle Fluid develop每日版本或基于Paddle develop分支源码编译安装,**推荐使用源码编译安装的方式**。 + +自定义OP编译方式如下: + + 进入 `ext_op/src` 目录,执行编译脚本 + ``` + cd ext_op/src + sh make.sh + ``` + + 成功编译后,`ext_op/src` 目录下将会生成 `pointnet2_lib.so` + + 执行下列操作,确保自定义算子编译正确: + + ``` + # 设置动态库的路径到 LD_LIBRARY_PATH 中 + export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:`python -c 'import paddle; print(paddle.sysconfig.get_lib())'` + + # 回到 ext_op 目录,添加 PYTHONPATH + cd .. + export PYTHONPATH=$PYTHONPATH:`pwd` + + # 运行单测 + python tests/test_farthest_point_sampling_op.py + python tests/test_gather_point_op.py + python tests/test_group_points_op.py + python tests/test_query_ball_op.py + python tests/test_three_interp_op.py + python tests/test_three_nn_op.py + ``` + 单测运行成功会输出提示信息,如下所示: + + ``` + . + ---------------------------------------------------------------------- + Ran 1 test in 13.205s + + OK + ``` + +**说明:** 自定义OP编译与[PointNet++](../PointNet++)下一致,更多关于自定义OP的编译说明,请参考[自定义OP编译](../PointNet++/ext_op/README.md) + +### 数据准备 + +**KITTI 3D object detection 数据集:** + +PointRCNN使用数据集[KITTI 3D object detection](http://www.cvlibs.net/datasets/kitti/eval_object.php?obj_benchmark=3d) +上进行训练。 + +可通过如下方式下载数据集: + +``` +cd data/KITTI/object +sh download.sh +``` + +此处的images只用做可视化,训练过程中使用[road planes](https://drive.google.com/file/d/1d5mq0RXRnvHPVeKx6Q612z0YRO1t2wAp/view?usp=sharing)数据来做训练时的数据增强, +请下载并解压至`./data/KITTI/object/training`目录下。 + +数据目录结构如下所示: + +``` +PointRCNN +├── data +│ ├── KITTI +│ │ ├── ImageSets +│ │ ├── object +│ │ │ ├──training +│ │ │ │ ├──calib & velodyne & label_2 & image_2 & planes +│ │ │ ├──testing +│ │ │ │ ├──calib & velodyne & image_2 + +``` + + +### 训练 + +**PointRCNN模型:** + +可通过如下方式启动 PointRCNN模型的训练: + +1. 指定单卡训练并设置动态库路径 + +``` +# 指定单卡GPU训练 +export CUDA_VISIBLE_DEVICES=0 + +# 设置动态库的路径到 LD_LIBRARY_PATH 中 +export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:`python -c 'import paddle; print(paddle.sysconfig.get_lib())'` +``` + +2. 生成Groud Truth采样数据,命令如下: + +``` +python tools/generate_gt_database.py --class_name 'Car' --split train +``` + +3. 训练 RPN 模型 + +``` +python train.py --cfg=./cfgs/default.yml \ + --train_mode=rpn \ + --batch_size=16 \ + --epoch=200 \ + --save_dir=checkpoints +``` + +RPN训练checkpoints默认保存在`checkpoints/rpn`目录,也可以通过`--save_dir`来指定。 + +4. 生成增强离线场景数据并保存RPN模型的输出特征和ROI,用于离线训练 RCNN 模型 + +生成增强的离线场景数据命令如下: + +``` +python tools/generate_aug_scene.py --class_name 'Car' --split train --aug_times 4 +``` + +保存RPN模型对离线增强数据的输出特征和ROI,可以通过参数`--ckpt_dir`来指定RPN训练最终权重保存路径,RPN权重默认保存在`checkpoints/rpn`目录。 +保存输出特征和ROI时须指定`TEST.SPLIT`为`train_aug`,指定`TEST.RPN_POST_NMS_TOP_N`为`300`, `TEST.RPN_NMS_THRESH`为`0.85`。 +通过`--output_dir`指定保存输出特征和ROI的路径,默认保存到`./output`目录。 + +``` +python eval.py --cfg=cfgs/default.yml \ + --eval_mode=rpn \ + --ckpt_dir=./checkpoints/rpn/199 \ + --save_rpn_feature \ + --output_dir=output \ + --set TEST.SPLIT train_aug TEST.RPN_POST_NMS_TOP_N 300 TEST.RPN_NMS_THRESH 0.85 +``` + +`--output_dir`下保存的数据目录结构如下: + +``` +output +├── detections +│ ├── data # 保存ROI数据 +│ │ ├── 000000.txt +│ │ ├── 000003.txt +│ │ ├── ... +├── features # 保存输出特征 +│ ├── 000000_intensity.npy +│ ├── 000000.npy +│ ├── 000000_rawscore.npy +│ ├── 000000_seg.npy +│ ├── 000000_xyz.npy +│ ├── ... +├── seg_result # 保存语义分割结果 +│ ├── 000000.npy +│ ├── 000003.npy +│ ├── ... +``` + +5. 离线训练RCNN,并且通过参数`--rcnn_training_roi_dir` and `--rcnn_training_feature_dir` 来指定 RPN 模型保存的输出特征和ROI路径。 + +``` +python train.py --cfg=./cfgs/default.yml \ + --train_mode=rcnn_offline \ + --batch_size=4 \ + --epoch=30 \ + --save_dir=checkpoints \ + --rcnn_training_roi_dir=output/detections/data \ + --rcnn_training_feature_dir=output/features +``` + +RCNN模型训练权重默认保存在`checkpoints/rcnn`目录下,可通过`--save_dir`参数指定。 + +**注意**: 最好的模型是通过保存RPN模型输出特征和ROI并离线数据增强的方式训练RCNN模型得出的,目前默认仅支持这种方式。 + + +### 模型评估 + +**PointRCNN模型:** + +可通过如下方式启动 PointRCNN 模型的评估: + +1. 指定单卡训练并设置动态库路径 + +``` +# 指定单卡GPU训练 +export CUDA_VISIBLE_DEVICES=0 + +# 设置动态库的路径到 LD_LIBRARY_PATH 中 +export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:`python -c 'import paddle; print(paddle.sysconfig.get_lib())'` + +``` + +2. 保存RPN模型对评估数据的输出特征和ROI + +保存RPN模型对评估数据的输出特征和ROI命令如下,可以通过参数`--ckpt_dir`来指定RPN训练最终权重保存路径,RPN权重默认保存在`checkpoints/rpn`目录。 +通过`--output_dir`指定保存输出特征和ROI的路径,默认保存到`./output`目录。 + +``` +python eval.py --cfg=cfgs/default.yml \ + --eval_mode=rpn \ + --ckpt_dir=./checkpoints/rpn/199 \ + --save_rpn_feature \ + --output_dir=output/val +``` + +保存RPN模型对评估数据的输出特征和ROI保存的目录结构与上述保存离线增强数据保存目录结构一致。 + +3. 评估离线RCNN模型 + +评估离线RCNN模型命令如下: + +``` +python eval.py --cfg=cfgs/default.yml \ + --eval_mode=rcnn_offline \ + --ckpt_dir=./checkpoints/rcnn_offline/29 \ + --rcnn_eval_roi_dir=output/val/detections/data \ + --rcnn_eval_feature_dir=output/val/features \ + --save_result +``` + +最终目标检测结果文件保存在`./result_dir`目录下`final_result`文件夹下,同时可通过`--save_result`开启保存`roi_output`和`refine_output`结果文件。 +`result_dir`目录结构如下: + +``` +result_dir +├── final_result +│ ├── data # 最终检测结果 +│ │ ├── 000001.txt +│ │ ├── 000002.txt +│ │ ├── ... +├── roi_output +│ ├── data # RCNN模型输出检测ROI结果 +│ │ ├── 000001.txt +│ │ ├── 000002.txt +│ │ ├── ... +├── refine_output +│ ├── data # 解码后的检测结果 +│ │ ├── 000001.txt +│ │ ├── 000002.txt +│ │ ├── ... +``` + +4. 使用KITTI mAP工具获得评估结果 + +若在评估过程中使用的python版本为3.6及以上版本,则程序会自动运行KITTI mAP评估,若使用python版本低于3.6, +由于KITTI mAP仅支持python 3.6及以上版本,须使用对应python版本通过如下命令进行评估: + +``` +python3 kitti_map.py +``` + +使用训练最终权重[RPN模型](https://paddlemodels.bj.bcebos.com/Paddle3D/pointrcnn_rpn.tar)和[RCNN模型](https://paddlemodels.bj.bcebos.com/Paddle3D/pointrcnn_rcnn_offline.tar)评估结果如下所示: + +| Car AP@ | 0.70(easy) | 0.70(moderate) | 0.70(hard) | +| :------- | :--------: | :------------: | :--------: | +| bbox AP: | 90.20 | 88.85 | 88.59 | +| bev AP: | 89.50 | 86.97 | 85.58 | +| 3d AP: | 86.66 | 76.65 | 75.90 | +| aos AP: | 90.10 | 88.64 | 88.26 | + + +## 参考文献 + +- [PointRCNN: 3D Object Proposal Generation and Detection From Point Cloud](https://arxiv.org/abs/1812.04244), Shaoshuai Shi, Xiaogang Wang, Hongsheng Li. +- [PointNet++: Deep Hierarchical Feature Learning on Point Sets in a Metric Space](https://arxiv.org/abs/1706.02413), Charles R. Qi, Li Yi, Hao Su, Leonidas J. Guibas. +- [PointNet: Deep Learning on Point Sets for 3D Classification and Segmentation](https://www.semanticscholar.org/paper/PointNet%3A-Deep-Learning-on-Point-Sets-for-3D-and-Qi-Su/d997beefc0922d97202789d2ac307c55c2c52fba), Charles Ruizhongtai Qi, Hao Su, Kaichun Mo, Leonidas J. Guibas. + +## 版本更新 + +- 11/2019, 新增 PointRCNN模型。 + diff --git a/PaddleCV/Paddle3D/PointRCNN/build_and_install.sh b/PaddleCV/Paddle3D/PointRCNN/build_and_install.sh new file mode 100644 index 00000000..83aaef84 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/build_and_install.sh @@ -0,0 +1,7 @@ +# compile cyops +python utils/cyops/setup.py develop + +# compile and install pts_utils +cd utils/pts_utils +python setup.py install +cd ../.. diff --git a/PaddleCV/Paddle3D/PointRCNN/cfgs/default.yml b/PaddleCV/Paddle3D/PointRCNN/cfgs/default.yml new file mode 100644 index 00000000..33dc4508 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/cfgs/default.yml @@ -0,0 +1,167 @@ +# This config is based on https://github.com/sshaoshuai/PointRCNN/blob/master/tools/cfgs/default.yaml +CLASSES: Car + +INCLUDE_SIMILAR_TYPE: True + +# config of augmentation +AUG_DATA: True +AUG_METHOD_LIST: ['rotation', 'scaling', 'flip'] +AUG_METHOD_PROB: [1.0, 1.0, 0.5] +AUG_ROT_RANGE: 18 + +GT_AUG_ENABLED: True +GT_EXTRA_NUM: 15 +GT_AUG_RAND_NUM: True +GT_AUG_APPLY_PROB: 1.0 +GT_AUG_HARD_RATIO: 0.6 + +PC_REDUCE_BY_RANGE: True +PC_AREA_SCOPE: [[-40, 40], [-1, 3], [0, 70.4]] # x, y, z scope in rect camera coords +CLS_MEAN_SIZE: [[1.52563191462, 1.62856739989, 3.88311640418]] + + +# 1. config of rpn network +RPN: + ENABLED: True + FIXED: False + + # config of input + USE_INTENSITY: False + + # config of bin-based loss + LOC_XZ_FINE: True + LOC_SCOPE: 3.0 + LOC_BIN_SIZE: 0.5 + NUM_HEAD_BIN: 12 + + # config of network structure + BACKBONE: pointnet2_msg + USE_BN: True + NUM_POINTS: 16384 + + SA_CONFIG: + NPOINTS: [4096, 1024, 256, 64] + RADIUS: [[0.1, 0.5], [0.5, 1.0], [1.0, 2.0], [2.0, 4.0]] + NSAMPLE: [[16, 32], [16, 32], [16, 32], [16, 32]] + MLPS: [[[16, 16, 32], [32, 32, 64]], + [[64, 64, 128], [64, 96, 128]], + [[128, 196, 256], [128, 196, 256]], + [[256, 256, 512], [256, 384, 512]]] + FP_MLPS: [[128, 128], [256, 256], [512, 512], [512, 512]] + CLS_FC: [128] + REG_FC: [128] + DP_RATIO: 0.5 + + # config of training + LOSS_CLS: SigmoidFocalLoss + FG_WEIGHT: 15 + FOCAL_ALPHA: [0.25, 0.75] + FOCAL_GAMMA: 2.0 + REG_LOSS_WEIGHT: [1.0, 1.0, 1.0, 1.0] + LOSS_WEIGHT: [1.0, 1.0] + NMS_TYPE: normal + + # config of testing + SCORE_THRESH: 0.3 + +# 2. config of rcnn network +RCNN: + ENABLED: True + + # config of input + ROI_SAMPLE_JIT: False + REG_AUG_METHOD: multiple # multiple, single, normal + ROI_FG_AUG_TIMES: 10 + + USE_RPN_FEATURES: True + USE_MASK: True + MASK_TYPE: seg + USE_INTENSITY: False + USE_DEPTH: True + USE_SEG_SCORE: False + + POOL_EXTRA_WIDTH: 1.0 + + # config of bin-based loss + LOC_SCOPE: 1.5 + LOC_BIN_SIZE: 0.5 + NUM_HEAD_BIN: 9 + LOC_Y_BY_BIN: False + LOC_Y_SCOPE: 0.5 + LOC_Y_BIN_SIZE: 0.25 + SIZE_RES_ON_ROI: False + + # config of network structure + USE_BN: False + DP_RATIO: 0.0 + + BACKBONE: pointnet # pointnet + XYZ_UP_LAYER: [128, 128] + + NUM_POINTS: 512 + SA_CONFIG: + NPOINTS: [128, 32, -1] + RADIUS: [0.2, 0.4, 100] + NSAMPLE: [64, 64, 64] + MLPS: [[128, 128, 128], + [128, 128, 256], + [256, 256, 512]] + CLS_FC: [256, 256] + REG_FC: [256, 256] + + # config of training + LOSS_CLS: BinaryCrossEntropy + FOCAL_ALPHA: [0.25, 0.75] + FOCAL_GAMMA: 2.0 + CLS_WEIGHT: [1.0, 1.0, 1.0] + CLS_FG_THRESH: 0.6 + CLS_BG_THRESH: 0.45 + CLS_BG_THRESH_LO: 0.05 + REG_FG_THRESH: 0.55 + FG_RATIO: 0.5 + ROI_PER_IMAGE: 64 + HARD_BG_RATIO: 0.8 + + # config of testing + SCORE_THRESH: 0.3 + NMS_THRESH: 0.1 + +# general training config +TRAIN: + SPLIT: train + VAL_SPLIT: smallval + + LR: 0.002 + LR_CLIP: 0.00001 + LR_DECAY: 0.5 + DECAY_STEP_LIST: [100, 150, 180, 200] + LR_WARMUP: True + WARMUP_MIN: 0.0002 + WARMUP_EPOCH: 1 + + BN_MOMENTUM: 0.1 + BN_DECAY: 0.5 + BNM_CLIP: 0.01 + BN_DECAY_STEP_LIST: [1000] + + OPTIMIZER: adam # adam, adam_onecycle + WEIGHT_DECAY: 0.001 # L2 regularization + MOMENTUM: 0.9 + + MOMS: [0.95, 0.85] + DIV_FACTOR: 10.0 + PCT_START: 0.4 + + GRAD_NORM_CLIP: 1.0 + + RPN_PRE_NMS_TOP_N: 9000 + RPN_POST_NMS_TOP_N: 512 + RPN_NMS_THRESH: 0.85 + RPN_DISTANCE_BASED_PROPOSE: True + +TEST: + SPLIT: val + RPN_PRE_NMS_TOP_N: 9000 + RPN_POST_NMS_TOP_N: 100 + RPN_NMS_THRESH: 0.8 + RPN_DISTANCE_BASED_PROPOSE: True diff --git a/PaddleCV/Paddle3D/PointRCNN/data/KITTI/object/download.sh b/PaddleCV/Paddle3D/PointRCNN/data/KITTI/object/download.sh new file mode 100644 index 00000000..1f5818d3 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/data/KITTI/object/download.sh @@ -0,0 +1,25 @@ +DIR="$( cd "$(dirname "$0")" ; pwd -P )" +cd "$DIR" + +echo "Downloading https://s3.eu-central-1.amazonaws.com/avg-kitti/data_object_velodyne.zip" +wget https://s3.eu-central-1.amazonaws.com/avg-kitti/data_object_velodyne.zip +echo "https://s3.eu-central-1.amazonaws.com/avg-kitti/data_object_image_2.zip" +wget https://s3.eu-central-1.amazonaws.com/avg-kitti/data_object_image_2.zip +echo "https://s3.eu-central-1.amazonaws.com/avg-kitti/data_object_calib.zip" +wget https://s3.eu-central-1.amazonaws.com/avg-kitti/data_object_calib.zip +echo "https://s3.eu-central-1.amazonaws.com/avg-kitti/data_object_label_2.zip" +wget https://s3.eu-central-1.amazonaws.com/avg-kitti/data_object_label_2.zip + +echo "Decompressing data_object_velodyne.zip" +unzip data_object_velodyne.zip +echo "Decompressing data_object_image_2.zip" +unzip "data_object_image_2.zip" +echo "Decompressing data_object_calib.zip" +unzip data_object_calib.zip +echo "Decompressing data_object_label_2.zip" +unzip data_object_label_2.zip + +echo "Download KITTI ImageSets" +wget https://paddlemodels.bj.bcebos.com/Paddle3D/pointrcnn_kitti_imagesets.tar +tar xf pointrcnn_kitti_imagesets.tar +mv ImageSets .. diff --git a/PaddleCV/Paddle3D/PointRCNN/data/__init__.py b/PaddleCV/Paddle3D/PointRCNN/data/__init__.py new file mode 100644 index 00000000..46a4f6ee --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/data/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +#Licensed under the Apache License, Version 2.0 (the "License"); +#you may not use this file except in compliance with the License. +#You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +#Unless required by applicable law or agreed to in writing, software +#distributed under the License is distributed on an "AS IS" BASIS, +#WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#See the License for the specific language governing permissions and +#limitations under the License. diff --git a/PaddleCV/Paddle3D/PointRCNN/data/kitti_dataset.py b/PaddleCV/Paddle3D/PointRCNN/data/kitti_dataset.py new file mode 100644 index 00000000..0765a504 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/data/kitti_dataset.py @@ -0,0 +1,77 @@ +""" +This code is based on https://github.com/sshaoshuai/PointRCNN/blob/master/lib/datasets/kitti_dataset.py +""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import os +import cv2 +import numpy as np +import utils.calibration as calibration +from utils.object3d import get_objects_from_label +from PIL import Image + +__all__ = ["KittiDataset"] + + +class KittiDataset(object): + def __init__(self, data_dir, split='train'): + assert split in ['train', 'train_aug', 'val', 'test'], "unknown split {}".format(split) + self.split = split + self.is_test = self.split == 'test' + self.imageset_dir = os.path.join(data_dir, 'KITTI', 'object', 'testing' if self.is_test else 'training') + + split_dir = os.path.join(data_dir, 'KITTI', 'ImageSets', split + '.txt') + self.image_idx_list = [x.strip() for x in open(split_dir).readlines()] + self.num_sample = self.image_idx_list.__len__() + + self.image_dir = os.path.join(self.imageset_dir, 'image_2') + self.lidar_dir = os.path.join(self.imageset_dir, 'velodyne') + self.calib_dir = os.path.join(self.imageset_dir, 'calib') + self.label_dir = os.path.join(self.imageset_dir, 'label_2') + self.plane_dir = os.path.join(self.imageset_dir, 'planes') + + def get_image(self, idx): + img_file = os.path.join(self.image_dir, '%06d.png' % idx) + assert os.path.exists(img_file) + return cv2.imread(img_file) # (H, W, 3) BGR mode + + def get_image_shape(self, idx): + img_file = os.path.join(self.image_dir, '%06d.png' % idx) + assert os.path.exists(img_file) + im = Image.open(img_file) + width, height = im.size + return height, width, 3 + + def get_lidar(self, idx): + lidar_file = os.path.join(self.lidar_dir, '%06d.bin' % idx) + assert os.path.exists(lidar_file) + return np.fromfile(lidar_file, dtype=np.float32).reshape(-1, 4) + + def get_calib(self, idx): + calib_file = os.path.join(self.calib_dir, '%06d.txt' % idx) + assert os.path.exists(calib_file) + return calibration.Calibration(calib_file) + + def get_label(self, idx): + label_file = os.path.join(self.label_dir, '%06d.txt' % idx) + assert os.path.exists(label_file) + # return kitti_utils.get_objects_from_label(label_file) + return get_objects_from_label(label_file) + + def get_road_plane(self, idx): + plane_file = os.path.join(self.plane_dir, '%06d.txt' % idx) + with open(plane_file, 'r') as f: + lines = f.readlines() + lines = [float(i) for i in lines[3].split()] + plane = np.asarray(lines) + + # Ensure normal is always facing up, this is in the rectified camera coordinate + if plane[1] > 0: + plane = -plane + + norm = np.linalg.norm(plane[0:3]) + plane = plane / norm + return plane diff --git a/PaddleCV/Paddle3D/PointRCNN/data/kitti_rcnn_reader.py b/PaddleCV/Paddle3D/PointRCNN/data/kitti_rcnn_reader.py new file mode 100644 index 00000000..811a20b2 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/data/kitti_rcnn_reader.py @@ -0,0 +1,1184 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +#Licensed under the Apache License, Version 2.0 (the "License"); +#you may not use this file except in compliance with the License. +#You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +#Unless required by applicable law or agreed to in writing, software +#distributed under the License is distributed on an "AS IS" BASIS, +#WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#See the License for the specific language governing permissions and +#limitations under the License. +""" +This code is based on https://github.com/sshaoshuai/PointRCNN/blob/master/lib/datasets/kitti_rcnn_dataset.py +""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import os +import logging +import multiprocessing +import numpy as np +import scipy +from scipy.spatial import Delaunay +try: + import cPickle as pickle +except: + import pickle + +import pts_utils +import utils.cyops.kitti_utils as kitti_utils +import utils.cyops.roipool3d_utils as roipool3d_utils +from data.kitti_dataset import KittiDataset +from utils.config import cfg +from collections import OrderedDict + +__all__ = ["KittiRCNNReader"] + +logger = logging.getLogger(__name__) + + +def has_empty(data): + for d in data: + if isinstance(d, np.ndarray) and len(d) == 0: + return True + return False + + +def in_hull(p, hull): + """ + :param p: (N, K) test points + :param hull: (M, K) M corners of a box + :return (N) bool + """ + try: + if not isinstance(hull, Delaunay): + hull = Delaunay(hull) + flag = hull.find_simplex(p) >= 0 + except scipy.spatial.qhull.QhullError: + logger.debug('Warning: not a hull.') + flag = np.zeros(p.shape[0], dtype=np.bool) + + return flag + + +class KittiRCNNReader(KittiDataset): + def __init__(self, data_dir, npoints=16384, split='train', classes='Car', mode='TRAIN', + random_select=True, rcnn_training_roi_dir=None, rcnn_training_feature_dir=None, + rcnn_eval_roi_dir=None, rcnn_eval_feature_dir=None, gt_database_dir=None): + super(KittiRCNNReader, self).__init__(data_dir=data_dir, split=split) + if classes == 'Car': + self.classes = ('Background', 'Car') + aug_scene_data_dir = os.path.join(data_dir, 'KITTI', 'aug_scene') + elif classes == 'People': + self.classes = ('Background', 'Pedestrian', 'Cyclist') + elif classes == 'Pedestrian': + self.classes = ('Background', 'Pedestrian') + aug_scene_data_dir = os.path.join(data_dir, 'KITTI', 'aug_scene_ped') + elif classes == 'Cyclist': + self.classes = ('Background', 'Cyclist') + aug_scene_data_dir = os.path.join(data_dir, 'KITTI', 'aug_scene_cyclist') + else: + assert False, "Invalid classes: %s" % classes + + self.num_classes = len(self.classes) + + self.npoints = npoints + self.sample_id_list = [] + self.random_select = random_select + + if split == 'train_aug': + self.aug_label_dir = os.path.join(aug_scene_data_dir, 'training', 'aug_label') + self.aug_pts_dir = os.path.join(aug_scene_data_dir, 'training', 'rectified_data') + else: + self.aug_label_dir = os.path.join(aug_scene_data_dir, 'training', 'aug_label') + self.aug_pts_dir = os.path.join(aug_scene_data_dir, 'training', 'rectified_data') + + # for rcnn training + self.rcnn_training_bbox_list = [] + self.rpn_feature_list = {} + self.pos_bbox_list = [] + self.neg_bbox_list = [] + self.far_neg_bbox_list = [] + self.rcnn_eval_roi_dir = rcnn_eval_roi_dir + self.rcnn_eval_feature_dir = rcnn_eval_feature_dir + self.rcnn_training_roi_dir = rcnn_training_roi_dir + self.rcnn_training_feature_dir = rcnn_training_feature_dir + + self.gt_database = None + + if not self.random_select: + logger.warning('random select is False') + + assert mode in ['TRAIN', 'EVAL', 'TEST'], 'Invalid mode: %s' % mode + self.mode = mode + + if cfg.RPN.ENABLED: + if gt_database_dir is not None: + self.gt_database = pickle.load(open(gt_database_dir, 'rb')) + + if cfg.GT_AUG_HARD_RATIO > 0: + easy_list, hard_list = [], [] + for k in range(self.gt_database.__len__()): + obj = self.gt_database[k] + if obj['points'].shape[0] > 100: + easy_list.append(obj) + else: + hard_list.append(obj) + self.gt_database = [easy_list, hard_list] + logger.info('Loading gt_database(easy(pt_num>100): %d, hard(pt_num<=100): %d) from %s' + % (len(easy_list), len(hard_list), gt_database_dir)) + else: + logger.info('Loading gt_database(%d) from %s' % (len(self.gt_database), gt_database_dir)) + + if mode == 'TRAIN': + self.preprocess_rpn_training_data() + else: + self.sample_id_list = [int(sample_id) for sample_id in self.image_idx_list] + logger.info('Load testing samples from %s' % self.imageset_dir) + logger.info('Done: total test samples %d' % len(self.sample_id_list)) + elif cfg.RCNN.ENABLED: + for idx in range(0, self.num_sample): + sample_id = int(self.image_idx_list[idx]) + obj_list = self.filtrate_objects(self.get_label(sample_id)) + if len(obj_list) == 0: + # logger.info('No gt classes: %06d' % sample_id) + continue + self.sample_id_list.append(sample_id) + + logger.info('Done: filter %s results for rcnn training: %d / %d\n' % + (self.mode, len(self.sample_id_list), len(self.image_idx_list))) + + def preprocess_rpn_training_data(self): + """ + Discard samples which don't have current classes, which will not be used for training. + Valid sample_id is stored in self.sample_id_list + """ + logger.info('Loading %s samples from %s ...' % (self.mode, self.label_dir)) + for idx in range(0, self.num_sample): + sample_id = int(self.image_idx_list[idx]) + obj_list = self.filtrate_objects(self.get_label(sample_id)) + if len(obj_list) == 0: + logger.debug('No gt classes: %06d' % sample_id) + continue + self.sample_id_list.append(sample_id) + + logger.info('Done: filter %s results: %d / %d\n' % (self.mode, len(self.sample_id_list), + len(self.image_idx_list))) + + def get_label(self, idx): + if idx < 10000: + label_file = os.path.join(self.label_dir, '%06d.txt' % idx) + else: + label_file = os.path.join(self.aug_label_dir, '%06d.txt' % idx) + + assert os.path.exists(label_file) + return kitti_utils.get_objects_from_label(label_file) + + def get_image(self, idx): + return super(KittiRCNNReader, self).get_image(idx % 10000) + + def get_image_shape(self, idx): + return super(KittiRCNNReader, self).get_image_shape(idx % 10000) + + def get_calib(self, idx): + return super(KittiRCNNReader, self).get_calib(idx % 10000) + + def get_road_plane(self, idx): + return super(KittiRCNNReader, self).get_road_plane(idx % 10000) + + @staticmethod + def get_rpn_features(rpn_feature_dir, idx): + rpn_feature_file = os.path.join(rpn_feature_dir, '%06d.npy' % idx) + rpn_xyz_file = os.path.join(rpn_feature_dir, '%06d_xyz.npy' % idx) + rpn_intensity_file = os.path.join(rpn_feature_dir, '%06d_intensity.npy' % idx) + if cfg.RCNN.USE_SEG_SCORE: + rpn_seg_file = os.path.join(rpn_feature_dir, '%06d_rawscore.npy' % idx) + rpn_seg_score = np.load(rpn_seg_file).reshape(-1) + rpn_seg_score = torch.sigmoid(torch.from_numpy(rpn_seg_score)).numpy() + else: + rpn_seg_file = os.path.join(rpn_feature_dir, '%06d_seg.npy' % idx) + rpn_seg_score = np.load(rpn_seg_file).reshape(-1) + return np.load(rpn_xyz_file), np.load(rpn_feature_file), np.load(rpn_intensity_file).reshape(-1), rpn_seg_score + + def filtrate_objects(self, obj_list): + """ + Discard objects which are not in self.classes (or its similar classes) + :param obj_list: list + :return: list + """ + type_whitelist = self.classes + if self.mode == 'TRAIN' and cfg.INCLUDE_SIMILAR_TYPE: + type_whitelist = list(self.classes) + if 'Car' in self.classes: + type_whitelist.append('Van') + if 'Pedestrian' in self.classes: # or 'Cyclist' in self.classes: + type_whitelist.append('Person_sitting') + + valid_obj_list = [] + for obj in obj_list: + if obj.cls_type not in type_whitelist: # rm Van, 20180928 + continue + if self.mode == 'TRAIN' and cfg.PC_REDUCE_BY_RANGE and (self.check_pc_range(obj.pos) is False): + continue + valid_obj_list.append(obj) + return valid_obj_list + + @staticmethod + def filtrate_dc_objects(obj_list): + valid_obj_list = [] + for obj in obj_list: + if obj.cls_type in ['DontCare']: + continue + valid_obj_list.append(obj) + + return valid_obj_list + + @staticmethod + def check_pc_range(xyz): + """ + :param xyz: [x, y, z] + :return: + """ + x_range, y_range, z_range = cfg.PC_AREA_SCOPE + if (x_range[0] <= xyz[0] <= x_range[1]) and (y_range[0] <= xyz[1] <= y_range[1]) and \ + (z_range[0] <= xyz[2] <= z_range[1]): + return True + return False + + @staticmethod + def get_valid_flag(pts_rect, pts_img, pts_rect_depth, img_shape): + """ + Valid point should be in the image (and in the PC_AREA_SCOPE) + :param pts_rect: + :param pts_img: + :param pts_rect_depth: + :param img_shape: + :return: + """ + val_flag_1 = np.logical_and(pts_img[:, 0] >= 0, pts_img[:, 0] < img_shape[1]) + val_flag_2 = np.logical_and(pts_img[:, 1] >= 0, pts_img[:, 1] < img_shape[0]) + val_flag_merge = np.logical_and(val_flag_1, val_flag_2) + pts_valid_flag = np.logical_and(val_flag_merge, pts_rect_depth >= 0) + + if cfg.PC_REDUCE_BY_RANGE: + x_range, y_range, z_range = cfg.PC_AREA_SCOPE + pts_x, pts_y, pts_z = pts_rect[:, 0], pts_rect[:, 1], pts_rect[:, 2] + range_flag = (pts_x >= x_range[0]) & (pts_x <= x_range[1]) \ + & (pts_y >= y_range[0]) & (pts_y <= y_range[1]) \ + & (pts_z >= z_range[0]) & (pts_z <= z_range[1]) + pts_valid_flag = pts_valid_flag & range_flag + return pts_valid_flag + + def get_rpn_sample(self, index): + sample_id = int(self.sample_id_list[index]) + if sample_id < 10000: + calib = self.get_calib(sample_id) + # img = self.get_image(sample_id) + img_shape = self.get_image_shape(sample_id) + pts_lidar = self.get_lidar(sample_id) + + # get valid point (projected points should be in image) + pts_rect = calib.lidar_to_rect(pts_lidar[:, 0:3]) + pts_intensity = pts_lidar[:, 3] + else: + calib = self.get_calib(sample_id % 10000) + # img = self.get_image(sample_id % 10000) + img_shape = self.get_image_shape(sample_id % 10000) + + pts_file = os.path.join(self.aug_pts_dir, '%06d.bin' % sample_id) + assert os.path.exists(pts_file), '%s' % pts_file + aug_pts = np.fromfile(pts_file, dtype=np.float32).reshape(-1, 4) + pts_rect, pts_intensity = aug_pts[:, 0:3], aug_pts[:, 3] + + pts_img, pts_rect_depth = calib.rect_to_img(pts_rect) + pts_valid_flag = self.get_valid_flag(pts_rect, pts_img, pts_rect_depth, img_shape) + + pts_rect = pts_rect[pts_valid_flag][:, 0:3] + pts_intensity = pts_intensity[pts_valid_flag] + + if cfg.GT_AUG_ENABLED and self.mode == 'TRAIN': + # all labels for checking overlapping + all_gt_obj_list = self.filtrate_dc_objects(self.get_label(sample_id)) + all_gt_boxes3d = kitti_utils.objs_to_boxes3d(all_gt_obj_list) + + gt_aug_flag = False + if np.random.rand() < cfg.GT_AUG_APPLY_PROB: + # augment one scene + gt_aug_flag, pts_rect, pts_intensity, extra_gt_boxes3d, extra_gt_obj_list = \ + self.apply_gt_aug_to_one_scene(sample_id, pts_rect, pts_intensity, all_gt_boxes3d) + + # generate inputs + if self.mode == 'TRAIN' or self.random_select: + if self.npoints < len(pts_rect): + pts_depth = pts_rect[:, 2] + pts_near_flag = pts_depth < 40.0 + far_idxs_choice = np.where(pts_near_flag == 0)[0] + near_idxs = np.where(pts_near_flag == 1)[0] + near_idxs_choice = np.random.choice(near_idxs, self.npoints - len(far_idxs_choice), replace=False) + + choice = np.concatenate((near_idxs_choice, far_idxs_choice), axis=0) \ + if len(far_idxs_choice) > 0 else near_idxs_choice + np.random.shuffle(choice) + else: + choice = np.arange(0, len(pts_rect), dtype=np.int32) + if self.npoints > len(pts_rect): + extra_choice = np.random.choice(choice, self.npoints - len(pts_rect), replace=False) + choice = np.concatenate((choice, extra_choice), axis=0) + np.random.shuffle(choice) + + ret_pts_rect = pts_rect[choice, :] + ret_pts_intensity = pts_intensity[choice] - 0.5 # translate intensity to [-0.5, 0.5] + else: + ret_pts_rect = np.zeros((self.npoints, pts_rect.shape[1])).astype(pts_rect.dtype) + num_ = min(self.npoints, pts_rect.shape[0]) + ret_pts_rect[:num_] = pts_rect[:num_] + + ret_pts_intensity = pts_intensity - 0.5 + + pts_features = [ret_pts_intensity.reshape(-1, 1)] + ret_pts_features = np.concatenate(pts_features, axis=1) if pts_features.__len__() > 1 else pts_features[0] + + sample_info = {'sample_id': sample_id, 'random_select': self.random_select} + + if self.mode == 'TEST': + if cfg.RPN.USE_INTENSITY: + pts_input = np.concatenate((ret_pts_rect, ret_pts_features), axis=1) # (N, C) + else: + pts_input = ret_pts_rect + sample_info['pts_input'] = pts_input + sample_info['pts_rect'] = ret_pts_rect + sample_info['pts_features'] = ret_pts_features + return sample_info + + gt_obj_list = self.filtrate_objects(self.get_label(sample_id)) + if cfg.GT_AUG_ENABLED and self.mode == 'TRAIN' and gt_aug_flag: + gt_obj_list.extend(extra_gt_obj_list) + gt_boxes3d = kitti_utils.objs_to_boxes3d(gt_obj_list) + + gt_alpha = np.zeros((gt_obj_list.__len__()), dtype=np.float32) + for k, obj in enumerate(gt_obj_list): + gt_alpha[k] = obj.alpha + + # data augmentation + aug_pts_rect = ret_pts_rect.copy() + aug_gt_boxes3d = gt_boxes3d.copy() + if cfg.AUG_DATA and self.mode == 'TRAIN': + aug_pts_rect, aug_gt_boxes3d, aug_method = self.data_augmentation(aug_pts_rect, aug_gt_boxes3d, gt_alpha, + sample_id) + sample_info['aug_method'] = aug_method + + # prepare input + if cfg.RPN.USE_INTENSITY: + pts_input = np.concatenate((aug_pts_rect, ret_pts_features), axis=1) # (N, C) + else: + pts_input = aug_pts_rect + + if cfg.RPN.FIXED: + sample_info['pts_input'] = pts_input + sample_info['pts_rect'] = aug_pts_rect + sample_info['pts_features'] = ret_pts_features + sample_info['gt_boxes3d'] = aug_gt_boxes3d + return sample_info + + if self.mode == 'EVAL' and aug_gt_boxes3d.shape[0] == 0: + aug_gt_boxes3d = np.zeros((1, aug_gt_boxes3d.shape[1])) + + # generate training labels + rpn_cls_label, rpn_reg_label = self.generate_rpn_training_labels(aug_pts_rect, aug_gt_boxes3d) + sample_info['pts_input'] = pts_input + sample_info['pts_rect'] = aug_pts_rect + sample_info['pts_features'] = ret_pts_features + sample_info['rpn_cls_label'] = rpn_cls_label + sample_info['rpn_reg_label'] = rpn_reg_label + sample_info['gt_boxes3d'] = aug_gt_boxes3d + return sample_info + + def apply_gt_aug_to_one_scene(self, sample_id, pts_rect, pts_intensity, all_gt_boxes3d): + """ + :param pts_rect: (N, 3) + :param all_gt_boxex3d: (M2, 7) + :return: + """ + assert self.gt_database is not None + # extra_gt_num = np.random.randint(10, 15) + # try_times = 50 + if cfg.GT_AUG_RAND_NUM: + extra_gt_num = np.random.randint(10, cfg.GT_EXTRA_NUM) + else: + extra_gt_num = cfg.GT_EXTRA_NUM + try_times = 100 + cnt = 0 + cur_gt_boxes3d = all_gt_boxes3d.copy() + cur_gt_boxes3d[:, 4] += 0.5 # TODO: consider different objects + cur_gt_boxes3d[:, 5] += 0.5 # enlarge new added box to avoid too nearby boxes + cur_gt_corners = kitti_utils.boxes3d_to_corners3d(cur_gt_boxes3d) + + extra_gt_obj_list = [] + extra_gt_boxes3d_list = [] + new_pts_list, new_pts_intensity_list = [], [] + src_pts_flag = np.ones(pts_rect.shape[0], dtype=np.int32) + + road_plane = self.get_road_plane(sample_id) + a, b, c, d = road_plane + + while try_times > 0: + if cnt > extra_gt_num: + break + + try_times -= 1 + if cfg.GT_AUG_HARD_RATIO > 0: + p = np.random.rand() + if p > cfg.GT_AUG_HARD_RATIO: + # use easy sample + rand_idx = np.random.randint(0, len(self.gt_database[0])) + new_gt_dict = self.gt_database[0][rand_idx] + else: + # use hard sample + rand_idx = np.random.randint(0, len(self.gt_database[1])) + new_gt_dict = self.gt_database[1][rand_idx] + else: + rand_idx = np.random.randint(0, self.gt_database.__len__()) + new_gt_dict = self.gt_database[rand_idx] + + new_gt_box3d = new_gt_dict['gt_box3d'].copy() + new_gt_points = new_gt_dict['points'].copy() + new_gt_intensity = new_gt_dict['intensity'].copy() + new_gt_obj = new_gt_dict['obj'] + center = new_gt_box3d[0:3] + if cfg.PC_REDUCE_BY_RANGE and (self.check_pc_range(center) is False): + continue + + if new_gt_points.__len__() < 5: # too few points + continue + + # put it on the road plane + cur_height = (-d - a * center[0] - c * center[2]) / b + move_height = new_gt_box3d[1] - cur_height + new_gt_box3d[1] -= move_height + new_gt_points[:, 1] -= move_height + new_gt_obj.pos[1] -= move_height + + new_enlarged_box3d = new_gt_box3d.copy() + new_enlarged_box3d[4] += 0.5 + new_enlarged_box3d[5] += 0.5 # enlarge new added box to avoid too nearby boxes + + cnt += 1 + new_corners = kitti_utils.boxes3d_to_corners3d(new_enlarged_box3d.reshape(1, 7)) + iou3d = kitti_utils.get_iou3d(new_corners, cur_gt_corners) + valid_flag = iou3d.max() < 1e-8 + if not valid_flag: + continue + + enlarged_box3d = new_gt_box3d.copy() + enlarged_box3d[3] += 2 # remove the points above and below the object + + boxes_pts_mask_list = pts_utils.pts_in_boxes3d(pts_rect, + enlarged_box3d.reshape(1, 7)) + pt_mask_flag = (boxes_pts_mask_list[0] == 1) + src_pts_flag[pt_mask_flag] = 0 # remove the original points which are inside the new box + + new_pts_list.append(new_gt_points) + new_pts_intensity_list.append(new_gt_intensity) + cur_gt_boxes3d = np.concatenate((cur_gt_boxes3d, new_enlarged_box3d.reshape(1, 7)), axis=0) + cur_gt_corners = np.concatenate((cur_gt_corners, new_corners), axis=0) + extra_gt_boxes3d_list.append(new_gt_box3d.reshape(1, 7)) + extra_gt_obj_list.append(new_gt_obj) + + if new_pts_list.__len__() == 0: + return False, pts_rect, pts_intensity, None, None + + extra_gt_boxes3d = np.concatenate(extra_gt_boxes3d_list, axis=0) + # remove original points and add new points + pts_rect = pts_rect[src_pts_flag == 1] + pts_intensity = pts_intensity[src_pts_flag == 1] + new_pts_rect = np.concatenate(new_pts_list, axis=0) + new_pts_intensity = np.concatenate(new_pts_intensity_list, axis=0) + pts_rect = np.concatenate((pts_rect, new_pts_rect), axis=0) + pts_intensity = np.concatenate((pts_intensity, new_pts_intensity), axis=0) + + return True, pts_rect, pts_intensity, extra_gt_boxes3d, extra_gt_obj_list + + def rotate_box3d_along_y(self, box3d, rot_angle): + old_x, old_z, ry = box3d[0], box3d[2], box3d[6] + old_beta = np.arctan2(old_z, old_x) + alpha = -np.sign(old_beta) * np.pi / 2 + old_beta + ry + box3d = kitti_utils.rotate_pc_along_y(box3d.reshape(1, 7), rot_angle=rot_angle)[0] + new_x, new_z = box3d[0], box3d[2] + new_beta = np.arctan2(new_z, new_x) + box3d[6] = np.sign(new_beta) * np.pi / 2 + alpha - new_beta + return box3d + + def data_augmentation(self, aug_pts_rect, aug_gt_boxes3d, gt_alpha, sample_id=None, mustaug=False, stage=1): + """ + :param aug_pts_rect: (N, 3) + :param aug_gt_boxes3d: (N, 7) + :param gt_alpha: (N) + :return: + """ + aug_list = cfg.AUG_METHOD_LIST + aug_enable = 1 - np.random.rand(3) + if mustaug is True: + aug_enable[0] = -1 + aug_enable[1] = -1 + aug_method = [] + if 'rotation' in aug_list and aug_enable[0] < cfg.AUG_METHOD_PROB[0]: + angle = np.random.uniform(-np.pi / cfg.AUG_ROT_RANGE, np.pi / cfg.AUG_ROT_RANGE) + aug_pts_rect = kitti_utils.rotate_pc_along_y(aug_pts_rect, rot_angle=angle) + if stage == 1: + # xyz change, hwl unchange + aug_gt_boxes3d = kitti_utils.rotate_pc_along_y(aug_gt_boxes3d, rot_angle=angle) + + # calculate the ry after rotation + x, z = aug_gt_boxes3d[:, 0], aug_gt_boxes3d[:, 2] + beta = np.arctan2(z, x) + new_ry = np.sign(beta) * np.pi / 2 + gt_alpha - beta + aug_gt_boxes3d[:, 6] = new_ry # TODO: not in [-np.pi / 2, np.pi / 2] + elif stage == 2: + # for debug stage-2, this implementation has little float precision difference with the above one + assert aug_gt_boxes3d.shape[0] == 2 + aug_gt_boxes3d[0] = self.rotate_box3d_along_y(aug_gt_boxes3d[0], angle) + aug_gt_boxes3d[1] = self.rotate_box3d_along_y(aug_gt_boxes3d[1], angle) + else: + raise NotImplementedError + + aug_method.append(['rotation', angle]) + + if 'scaling' in aug_list and aug_enable[1] < cfg.AUG_METHOD_PROB[1]: + scale = np.random.uniform(0.95, 1.05) + aug_pts_rect = aug_pts_rect * scale + aug_gt_boxes3d[:, 0:6] = aug_gt_boxes3d[:, 0:6] * scale + aug_method.append(['scaling', scale]) + + if 'flip' in aug_list and aug_enable[2] < cfg.AUG_METHOD_PROB[2]: + # flip horizontal + aug_pts_rect[:, 0] = -aug_pts_rect[:, 0] + aug_gt_boxes3d[:, 0] = -aug_gt_boxes3d[:, 0] + # flip orientation: ry > 0: pi - ry, ry < 0: -pi - ry + if stage == 1: + aug_gt_boxes3d[:, 6] = np.sign(aug_gt_boxes3d[:, 6]) * np.pi - aug_gt_boxes3d[:, 6] + elif stage == 2: + assert aug_gt_boxes3d.shape[0] == 2 + aug_gt_boxes3d[0, 6] = np.sign(aug_gt_boxes3d[0, 6]) * np.pi - aug_gt_boxes3d[0, 6] + aug_gt_boxes3d[1, 6] = np.sign(aug_gt_boxes3d[1, 6]) * np.pi - aug_gt_boxes3d[1, 6] + else: + raise NotImplementedError + + aug_method.append('flip') + + return aug_pts_rect, aug_gt_boxes3d, aug_method + + @staticmethod + def generate_rpn_training_labels(pts_rect, gt_boxes3d): + cls_label = np.zeros((pts_rect.shape[0]), dtype=np.int32) + reg_label = np.zeros((pts_rect.shape[0], 7), dtype=np.float32) # dx, dy, dz, ry, h, w, l + gt_corners = kitti_utils.boxes3d_to_corners3d(gt_boxes3d, rotate=True) + extend_gt_boxes3d = kitti_utils.enlarge_box3d(gt_boxes3d, extra_width=0.2) + extend_gt_corners = kitti_utils.boxes3d_to_corners3d(extend_gt_boxes3d, rotate=True) + for k in range(gt_boxes3d.shape[0]): + box_corners = gt_corners[k] + fg_pt_flag = in_hull(pts_rect, box_corners) + fg_pts_rect = pts_rect[fg_pt_flag] + cls_label[fg_pt_flag] = 1 + + # enlarge the bbox3d, ignore nearby points + extend_box_corners = extend_gt_corners[k] + fg_enlarge_flag = in_hull(pts_rect, extend_box_corners) + ignore_flag = np.logical_xor(fg_pt_flag, fg_enlarge_flag) + cls_label[ignore_flag] = -1 + + # pixel offset of object center + center3d = gt_boxes3d[k][0:3].copy() # (x, y, z) + center3d[1] -= gt_boxes3d[k][3] / 2 + reg_label[fg_pt_flag, 0:3] = center3d - fg_pts_rect # Now y is the true center of 3d box 20180928 + + # size and angle encoding + reg_label[fg_pt_flag, 3] = gt_boxes3d[k][3] # h + reg_label[fg_pt_flag, 4] = gt_boxes3d[k][4] # w + reg_label[fg_pt_flag, 5] = gt_boxes3d[k][5] # l + reg_label[fg_pt_flag, 6] = gt_boxes3d[k][6] # ry + + return cls_label, reg_label + + def get_rcnn_sample_jit(self, index): + sample_id = int(self.sample_id_list[index]) + rpn_xyz, rpn_features, rpn_intensity, seg_mask = \ + self.get_rpn_features(self.rcnn_training_feature_dir, sample_id) + + # load rois and gt_boxes3d for this sample + roi_file = os.path.join(self.rcnn_training_roi_dir, '%06d.txt' % sample_id) + roi_obj_list = kitti_utils.get_objects_from_label(roi_file) + roi_boxes3d = kitti_utils.objs_to_boxes3d(roi_obj_list) + # roi_scores is not used currently + # roi_scores = kitti_utils.objs_to_scores(roi_obj_list) + + gt_obj_list = self.filtrate_objects(self.get_label(sample_id)) + gt_boxes3d = kitti_utils.objs_to_boxes3d(gt_obj_list) + sample_info = OrderedDict() + sample_info["sample_id"] = sample_id + sample_info['rpn_xyz'] = rpn_xyz + sample_info['rpn_features'] = rpn_features + sample_info['rpn_intensity'] = rpn_intensity + sample_info['seg_mask'] = seg_mask + sample_info['roi_boxes3d'] = roi_boxes3d + sample_info['pts_depth'] = np.linalg.norm(rpn_xyz, ord=2, axis=1) + sample_info['gt_boxes3d'] = gt_boxes3d + + return sample_info + + def sample_bg_inds(self, hard_bg_inds, easy_bg_inds, bg_rois_per_this_image): + if hard_bg_inds.size > 0 and easy_bg_inds.size > 0: + hard_bg_rois_num = int(bg_rois_per_this_image * cfg.RCNN.HARD_BG_RATIO) + easy_bg_rois_num = bg_rois_per_this_image - hard_bg_rois_num + + # sampling hard bg + rand_num = np.floor(np.random.rand(hard_bg_rois_num) * hard_bg_inds.size).astype(np.int32) + hard_bg_inds = hard_bg_inds[rand_num] + # sampling easy bg + rand_num = np.floor(np.random.rand(easy_bg_rois_num) * easy_bg_inds.size).astype(np.int32) + easy_bg_inds = easy_bg_inds[rand_num] + + bg_inds = np.concatenate([hard_bg_inds, easy_bg_inds], axis=0) + elif hard_bg_inds.size > 0 and easy_bg_inds.size == 0: + hard_bg_rois_num = bg_rois_per_this_image + # sampling hard bg + rand_num = np.floor(np.random.rand(hard_bg_rois_num) * hard_bg_inds.size).astype(np.int32) + bg_inds = hard_bg_inds[rand_num] + elif hard_bg_inds.size == 0 and easy_bg_inds.size > 0: + easy_bg_rois_num = bg_rois_per_this_image + # sampling easy bg + rand_num = np.floor(np.random.rand(easy_bg_rois_num) * easy_bg_inds.size).astype(np.int32) + bg_inds = easy_bg_inds[rand_num] + else: + raise NotImplementedError + + return bg_inds + + def aug_roi_by_noise_batch(self, roi_boxes3d, gt_boxes3d, aug_times=10): + """ + :param roi_boxes3d: (N, 7) + :param gt_boxes3d: (N, 7) + :return: + """ + iou_of_rois = np.zeros(roi_boxes3d.shape[0], dtype=np.float32) + for k in range(roi_boxes3d.__len__()): + temp_iou = cnt = 0 + roi_box3d = roi_boxes3d[k] + gt_box3d = gt_boxes3d[k] + pos_thresh = min(cfg.RCNN.REG_FG_THRESH, cfg.RCNN.CLS_FG_THRESH) + gt_corners = kitti_utils.boxes3d_to_corners3d(gt_box3d.reshape(1, 7), True) + aug_box3d = roi_box3d + while temp_iou < pos_thresh and cnt < aug_times: + if np.random.rand() < 0.2: + aug_box3d = roi_box3d # p=0.2 to keep the original roi box + else: + aug_box3d = self.random_aug_box3d(roi_box3d) + aug_corners = kitti_utils.boxes3d_to_corners3d(aug_box3d.reshape(1, 7), True) + iou3d = kitti_utils.get_iou3d(aug_corners, gt_corners) + temp_iou = iou3d[0][0] + cnt += 1 + roi_boxes3d[k] = aug_box3d + iou_of_rois[k] = temp_iou + return roi_boxes3d, iou_of_rois + + @staticmethod + def canonical_transform_batch(pts_input, roi_boxes3d, gt_boxes3d): + """ + :param pts_input: (N, npoints, 3 + C) + :param roi_boxes3d: (N, 7) + :param gt_boxes3d: (N, 7) + :return: + """ + roi_ry = roi_boxes3d[:, 6] % (2 * np.pi) # 0 ~ 2pi + roi_center = roi_boxes3d[:, 0:3] + # shift to center + pts_input[:, :, [0, 1, 2]] = pts_input[:, :, [0, 1, 2]] - roi_center.reshape(-1, 1, 3) + gt_boxes3d_ct = np.copy(gt_boxes3d) + gt_boxes3d_ct[:, 0:3] = gt_boxes3d_ct[:, 0:3] - roi_center + # rotate to the direction of head + gt_boxes3d_ct = kitti_utils.rotate_pc_along_y_np( + gt_boxes3d_ct.reshape(-1, 1, 7), + roi_ry, + ) + # TODO: check here + gt_boxes3d_ct = gt_boxes3d_ct.reshape(-1,7) + gt_boxes3d_ct[:, 6] = gt_boxes3d_ct[:, 6] - roi_ry + pts_input = kitti_utils.rotate_pc_along_y_np( + pts_input, + roi_ry + ) + return pts_input, gt_boxes3d_ct + + def get_rcnn_training_sample_batch(self, index): + sample_id = int(self.sample_id_list[index]) + rpn_xyz, rpn_features, rpn_intensity, seg_mask = \ + self.get_rpn_features(self.rcnn_training_feature_dir, sample_id) + + # load rois and gt_boxes3d for this sample + roi_file = os.path.join(self.rcnn_training_roi_dir, '%06d.txt' % sample_id) + roi_obj_list = kitti_utils.get_objects_from_label(roi_file) + roi_boxes3d = kitti_utils.objs_to_boxes3d(roi_obj_list) + # roi_scores = kitti_utils.objs_to_scores(roi_obj_list) + + gt_obj_list = self.filtrate_objects(self.get_label(sample_id)) + gt_boxes3d = kitti_utils.objs_to_boxes3d(gt_obj_list) + + # calculate original iou + iou3d = kitti_utils.get_iou3d(kitti_utils.boxes3d_to_corners3d(roi_boxes3d, True), + kitti_utils.boxes3d_to_corners3d(gt_boxes3d, True)) + max_overlaps, gt_assignment = iou3d.max(axis=1), iou3d.argmax(axis=1) + max_iou_of_gt, roi_assignment = iou3d.max(axis=0), iou3d.argmax(axis=0) + roi_assignment = roi_assignment[max_iou_of_gt > 0].reshape(-1) + + # sample fg, easy_bg, hard_bg + fg_rois_per_image = int(np.round(cfg.RCNN.FG_RATIO * cfg.RCNN.ROI_PER_IMAGE)) + fg_thresh = min(cfg.RCNN.REG_FG_THRESH, cfg.RCNN.CLS_FG_THRESH) + fg_inds = np.nonzero(max_overlaps >= fg_thresh)[0] + fg_inds = np.concatenate((fg_inds, roi_assignment), axis=0) # consider the roi which has max_overlaps with gt as fg + + easy_bg_inds = np.nonzero((max_overlaps < cfg.RCNN.CLS_BG_THRESH_LO))[0] + hard_bg_inds = np.nonzero((max_overlaps < cfg.RCNN.CLS_BG_THRESH) & + (max_overlaps >= cfg.RCNN.CLS_BG_THRESH_LO))[0] + + fg_num_rois = fg_inds.size + bg_num_rois = hard_bg_inds.size + easy_bg_inds.size + + if fg_num_rois > 0 and bg_num_rois > 0: + # sampling fg + fg_rois_per_this_image = min(fg_rois_per_image, fg_num_rois) + rand_num = np.random.permutation(fg_num_rois) + fg_inds = fg_inds[rand_num[:fg_rois_per_this_image]] + + # sampling bg + bg_rois_per_this_image = cfg.RCNN.ROI_PER_IMAGE - fg_rois_per_this_image + bg_inds = self.sample_bg_inds(hard_bg_inds, easy_bg_inds, bg_rois_per_this_image) + + elif fg_num_rois > 0 and bg_num_rois == 0: + # sampling fg + rand_num = np.floor(np.random.rand(cfg.RCNN.ROI_PER_IMAGE ) * fg_num_rois) + # rand_num = torch.from_numpy(rand_num).type_as(gt_boxes3d).long() + fg_inds = fg_inds[rand_num] + fg_rois_per_this_image = cfg.RCNN.ROI_PER_IMAGE + bg_rois_per_this_image = 0 + elif bg_num_rois > 0 and fg_num_rois == 0: + # sampling bg + bg_rois_per_this_image = cfg.RCNN.ROI_PER_IMAGE + bg_inds = self.sample_bg_inds(hard_bg_inds, easy_bg_inds, bg_rois_per_this_image) + fg_rois_per_this_image = 0 + else: + import pdb + pdb.set_trace() + raise NotImplementedError + + # augment the rois by noise + roi_list, roi_iou_list, roi_gt_list = [], [], [] + if fg_rois_per_this_image > 0: + fg_rois_src = roi_boxes3d[fg_inds].copy() + gt_of_fg_rois = gt_boxes3d[gt_assignment[fg_inds]] + fg_rois, fg_iou3d = self.aug_roi_by_noise_batch(fg_rois_src, gt_of_fg_rois, aug_times=10) + roi_list.append(fg_rois) + roi_iou_list.append(fg_iou3d) + roi_gt_list.append(gt_of_fg_rois) + + if bg_rois_per_this_image > 0: + bg_rois_src = roi_boxes3d[bg_inds].copy() + gt_of_bg_rois = gt_boxes3d[gt_assignment[bg_inds]] + bg_rois, bg_iou3d = self.aug_roi_by_noise_batch(bg_rois_src, gt_of_bg_rois, aug_times=1) + roi_list.append(bg_rois) + roi_iou_list.append(bg_iou3d) + roi_gt_list.append(gt_of_bg_rois) + + rois = np.concatenate(roi_list, axis=0) + iou_of_rois = np.concatenate(roi_iou_list, axis=0) + gt_of_rois = np.concatenate(roi_gt_list, axis=0) + + # collect extra features for point cloud pooling + if cfg.RCNN.USE_INTENSITY: + pts_extra_input_list = [rpn_intensity.reshape(-1, 1), seg_mask.reshape(-1, 1)] + else: + pts_extra_input_list = [seg_mask.reshape(-1, 1)] + + if cfg.RCNN.USE_DEPTH: + pts_depth = (np.linalg.norm(rpn_xyz, ord=2, axis=1) / 70.0) - 0.5 + pts_extra_input_list.append(pts_depth.reshape(-1, 1)) + pts_extra_input = np.concatenate(pts_extra_input_list, axis=1) + + # pts, pts_feature, boxes3d, pool_extra_width, sampled_pt_num + pts_input, pts_features, pts_empty_flag = roipool3d_utils.roipool3d_cpu( + rpn_xyz, rpn_features, rois, pts_extra_input, + cfg.RCNN.POOL_EXTRA_WIDTH, + sampled_pt_num=cfg.RCNN.NUM_POINTS, + #canonical_transform=False + ) + + # data augmentation + if cfg.AUG_DATA and self.mode == 'TRAIN': + for k in range(rois.__len__()): + aug_pts = pts_input[k, :, 0:3].copy() + aug_gt_box3d = gt_of_rois[k].copy() + aug_roi_box3d = rois[k].copy() + + # calculate alpha by ry + temp_boxes3d = np.concatenate([aug_roi_box3d.reshape(1, 7), aug_gt_box3d.reshape(1, 7)], axis=0) + temp_x, temp_z, temp_ry = temp_boxes3d[:, 0], temp_boxes3d[:, 2], temp_boxes3d[:, 6] + temp_beta = np.arctan2(temp_z, temp_x).astype(np.float64) + temp_alpha = -np.sign(temp_beta) * np.pi / 2 + temp_beta + temp_ry + + # data augmentation + aug_pts, aug_boxes3d, aug_method = self.data_augmentation(aug_pts, temp_boxes3d, temp_alpha, + mustaug=True, stage=2) + + # assign to original data + pts_input[k, :, 0:3] = aug_pts + rois[k] = aug_boxes3d[0] + gt_of_rois[k] = aug_boxes3d[1] + + valid_mask = (pts_empty_flag == 0).astype(np.int32) + # regression valid mask + reg_valid_mask = (iou_of_rois > cfg.RCNN.REG_FG_THRESH).astype(np.int32) & valid_mask + + # classification label + cls_label = (iou_of_rois > cfg.RCNN.CLS_FG_THRESH).astype(np.int32) + invalid_mask = (iou_of_rois > cfg.RCNN.CLS_BG_THRESH) & (iou_of_rois < cfg.RCNN.CLS_FG_THRESH) + cls_label[invalid_mask] = -1 + cls_label[valid_mask == 0] = -1 + + # canonical transform and sampling + pts_input_ct, gt_boxes3d_ct = self.canonical_transform_batch(pts_input, rois, gt_of_rois) + + pts_input_ = np.concatenate((pts_input_ct, pts_features), axis=-1) + sample_info = OrderedDict() + + sample_info['sample_id'] = sample_id + sample_info['pts_input'] = pts_input_ + sample_info['pts_feature'] = pts_features + sample_info['roi_boxes3d'] = rois + sample_info['cls_label'] = cls_label + sample_info['reg_valid_mask'] = reg_valid_mask + sample_info['gt_boxes3d_ct'] = gt_boxes3d_ct + sample_info['gt_of_rois'] = gt_of_rois + return sample_info + + @staticmethod + def random_aug_box3d(box3d): + """ + :param box3d: (7) [x, y, z, h, w, l, ry] + random shift, scale, orientation + """ + if cfg.RCNN.REG_AUG_METHOD == 'single': + pos_shift = (np.random.rand(3) - 0.5) # [-0.5 ~ 0.5] + hwl_scale = (np.random.rand(3) - 0.5) / (0.5 / 0.15) + 1.0 # + angle_rot = (np.random.rand(1) - 0.5) / (0.5 / (np.pi / 12)) # [-pi/12 ~ pi/12] + + aug_box3d = np.concatenate([box3d[0:3] + pos_shift, box3d[3:6] * hwl_scale, + box3d[6:7] + angle_rot]) + return aug_box3d + elif cfg.RCNN.REG_AUG_METHOD == 'multiple': + # pos_range, hwl_range, angle_range, mean_iou + range_config = [[0.2, 0.1, np.pi / 12, 0.7], + [0.3, 0.15, np.pi / 12, 0.6], + [0.5, 0.15, np.pi / 9, 0.5], + [0.8, 0.15, np.pi / 6, 0.3], + [1.0, 0.15, np.pi / 3, 0.2]] + idx = np.random.randint(len(range_config)) + + pos_shift = ((np.random.rand(3) - 0.5) / 0.5) * range_config[idx][0] + hwl_scale = ((np.random.rand(3) - 0.5) / 0.5) * range_config[idx][1] + 1.0 + angle_rot = ((np.random.rand(1) - 0.5) / 0.5) * range_config[idx][2] + + aug_box3d = np.concatenate([box3d[0:3] + pos_shift, box3d[3:6] * hwl_scale, box3d[6:7] + angle_rot]) + return aug_box3d + elif cfg.RCNN.REG_AUG_METHOD == 'normal': + x_shift = np.random.normal(loc=0, scale=0.3) + y_shift = np.random.normal(loc=0, scale=0.2) + z_shift = np.random.normal(loc=0, scale=0.3) + h_shift = np.random.normal(loc=0, scale=0.25) + w_shift = np.random.normal(loc=0, scale=0.15) + l_shift = np.random.normal(loc=0, scale=0.5) + ry_shift = ((np.random.rand() - 0.5) / 0.5) * np.pi / 12 + + aug_box3d = np.array([box3d[0] + x_shift, box3d[1] + y_shift, box3d[2] + z_shift, box3d[3] + h_shift, + box3d[4] + w_shift, box3d[5] + l_shift, box3d[6] + ry_shift]) + return aug_box3d + else: + raise NotImplementedError + + def get_proposal_from_file(self, index): + sample_id = int(self.image_idx_list[index]) + proposal_file = os.path.join(self.rcnn_eval_roi_dir, '%06d.txt' % sample_id) + roi_obj_list = kitti_utils.get_objects_from_label(proposal_file) + + rpn_xyz, rpn_features, rpn_intensity, seg_mask = self.get_rpn_features(self.rcnn_eval_feature_dir, sample_id) + pts_rect, pts_rpn_features, pts_intensity = rpn_xyz, rpn_features, rpn_intensity + + roi_box3d_list, roi_scores = [], [] + for obj in roi_obj_list: + box3d = np.array([obj.pos[0], obj.pos[1], obj.pos[2], obj.h, obj.w, obj.l, obj.ry], dtype=np.float32) + roi_box3d_list.append(box3d.reshape(1, 7)) + roi_scores.append(obj.score) + + roi_boxes3d = np.concatenate(roi_box3d_list, axis=0) # (N, 7) + roi_scores = np.array(roi_scores, dtype=np.float32) # (N) + + if cfg.RCNN.ROI_SAMPLE_JIT: + sample_dict = {'sample_id': sample_id, + 'rpn_xyz': rpn_xyz, + 'rpn_features': rpn_features, + 'seg_mask': seg_mask, + 'roi_boxes3d': roi_boxes3d, + 'roi_scores': roi_scores, + 'pts_depth': np.linalg.norm(rpn_xyz, ord=2, axis=1)} + + if self.mode != 'TEST': + gt_obj_list = self.filtrate_objects(self.get_label(sample_id)) + gt_boxes3d = kitti_utils.objs_to_boxes3d(gt_obj_list) + + roi_corners = kitti_utils.boxes3d_to_corners3d(roi_boxes3d,True) + gt_corners = kitti_utils.boxes3d_to_corners3d(gt_boxes3d,True) + iou3d = kitti_utils.get_iou3d(roi_corners, gt_corners) + if gt_boxes3d.shape[0] > 0: + gt_iou = iou3d.max(axis=1) + else: + gt_iou = np.zeros(roi_boxes3d.shape[0]).astype(np.float32) + + sample_dict['gt_boxes3d'] = gt_boxes3d + sample_dict['gt_iou'] = gt_iou + return sample_dict + + if cfg.RCNN.USE_INTENSITY: + pts_extra_input_list = [pts_intensity.reshape(-1, 1), seg_mask.reshape(-1, 1)] + else: + pts_extra_input_list = [seg_mask.reshape(-1, 1)] + + if cfg.RCNN.USE_DEPTH: + cur_depth = np.linalg.norm(pts_rect, axis=1, ord=2) + cur_depth_norm = (cur_depth / 70.0) - 0.5 + pts_extra_input_list.append(cur_depth_norm.reshape(-1, 1)) + + pts_extra_input = np.concatenate(pts_extra_input_list, axis=1) + pts_input, pts_features, _ = roipool3d_utils.roipool3d_cpu( + pts_rect, pts_rpn_features, roi_boxes3d, pts_extra_input, + cfg.RCNN.POOL_EXTRA_WIDTH, sampled_pt_num=cfg.RCNN.NUM_POINTS, + canonical_transform=True + ) + pts_input = np.concatenate((pts_input, pts_features), axis=-1) + + sample_dict = OrderedDict() + sample_dict['sample_id'] = sample_id + sample_dict['pts_input'] = pts_input + sample_dict['pts_feature'] = pts_features + sample_dict['roi_boxes3d'] = roi_boxes3d + sample_dict['roi_scores'] = roi_scores + #sample_dict['roi_size'] = roi_boxes3d[:, 3:6] + + if self.mode == 'TEST': + return sample_dict + + gt_obj_list = self.filtrate_objects(self.get_label(sample_id)) + gt_boxes3d = np.zeros((gt_obj_list.__len__(), 7), dtype=np.float32) + + for k, obj in enumerate(gt_obj_list): + gt_boxes3d[k, 0:3], gt_boxes3d[k, 3], gt_boxes3d[k, 4], gt_boxes3d[k, 5], gt_boxes3d[k, 6] \ + = obj.pos, obj.h, obj.w, obj.l, obj.ry + + if gt_boxes3d.__len__() == 0: + gt_iou = np.zeros((roi_boxes3d.shape[0]), dtype=np.float32) + else: + roi_corners = kitti_utils.boxes3d_to_corners3d(roi_boxes3d,True) + gt_corners = kitti_utils.boxes3d_to_corners3d(gt_boxes3d,True) + iou3d = kitti_utils.get_iou3d(roi_corners, gt_corners) + gt_iou = iou3d.max(axis=1) + + sample_dict['gt_iou'] = gt_iou + sample_dict['gt_boxes3d'] = gt_boxes3d + + return sample_dict + + def __len__(self): + if cfg.RPN.ENABLED: + return len(self.sample_id_list) + elif cfg.RCNN.ENABLED: + if self.mode == 'TRAIN': + return len(self.sample_id_list) + else: + return len(self.image_idx_list) + else: + raise NotImplementedError + + def __getitem__(self, index): + if cfg.RPN.ENABLED: + return self.get_rpn_sample(index) + elif cfg.RCNN.ENABLED: + if self.mode == 'TRAIN': + if cfg.RCNN.ROI_SAMPLE_JIT: + return self.get_rcnn_sample_jit(index) + else: + return self.get_rcnn_training_sample_batch(index) + else: + return self.get_proposal_from_file(index) + else: + raise NotImplementedError + + def padding_batch(self, batch_data, batch_size): + max_roi = 0 + max_gt = 0 + + for k in range(batch_size): + # roi_boxes3d + max_roi = max(max_roi, batch_data[k][3].shape[0]) + # gt_boxes3d + max_gt = max(max_gt, batch_data[k][-1].shape[0]) + batch_roi_boxes3d = np.zeros((batch_size, max_roi, 7)) + batch_gt_boxes3d = np.zeros((batch_size, max_gt, 7), dtype=np.float32) + + for i, data in enumerate(batch_data): + roi_num = data[3].shape[0] + gt_num = data[-1].shape[0] + batch_roi_boxes3d[i,:roi_num,:] = data[3] + batch_gt_boxes3d[i,:gt_num,:] = data[-1] + + new_batch = [] + for i, data in enumerate(batch_data): + new_batch.append(data[:3]) + # roi_boxes3d + new_batch[i].append(batch_roi_boxes3d[i]) + # ... + new_batch[i].extend(data[4:7]) + # gt_boxes3d + new_batch[i].append(batch_gt_boxes3d[i]) + return new_batch + + def padding_batch_eval(self, batch_data, batch_size): + max_pts = 0 + max_feats = 0 + max_roi = 0 + max_score = 0 + max_iou = 0 + max_gt = 0 + + for k in range(batch_size): + # pts_input + max_pts = max(max_pts, batch_data[k][1].shape[0]) + # pts_feature + max_feats = max(max_feats, batch_data[k][2].shape[0]) + # roi_boxes3d + max_roi = max(max_roi, batch_data[k][3].shape[0]) + # gt_iou + max_iou = max(max_iou, batch_data[k][-2].shape[0]) + # gt_boxes3d + max_gt = max(max_gt, batch_data[k][-1].shape[0]) + batch_pts_input = np.zeros((batch_size, max_pts, 512, 133), dtype=np.float32) + batch_pts_feat = np.zeros((batch_size, max_feats, 512, 128), dtype=np.float32) + batch_roi_boxes3d = np.zeros((batch_size, max_roi, 7), dtype=np.float32) + batch_gt_iou = np.zeros((batch_size, max_iou), dtype=np.float32) + batch_gt_boxes3d = np.zeros((batch_size, max_gt, 7), dtype=np.float32) + + for i, data in enumerate(batch_data): + # num + pts_num = data[1].shape[0] + pts_feat_num = data[2].shape[0] + roi_num = data[3].shape[0] + iou_num = data[-2].shape[0] + gt_num = data[-1].shape[0] + # data + batch_pts_input[i, :pts_num, :, :] = data[1] + batch_pts_feat[i, :pts_feat_num, :, :] = data[2] + batch_roi_boxes3d[i,:roi_num,:] = data[3] + batch_gt_iou[i,:iou_num] = data[-2] + batch_gt_boxes3d[i,:gt_num,:] = data[-1] + + new_batch = [] + for i, data in enumerate(batch_data): + new_batch.append(data[:1]) + new_batch[i].append(batch_pts_input[i]) + new_batch[i].append(batch_pts_feat[i]) + new_batch[i].append(batch_roi_boxes3d[i]) + new_batch[i].append(data[4]) + new_batch[i].append(batch_gt_iou[i]) + new_batch[i].append(batch_gt_boxes3d[i]) + return new_batch + + def get_reader(self, batch_size, fields, drop_last=False): + def reader(): + batch_out = [] + idxs = np.arange(self.__len__()) + if self.mode == 'TRAIN': + np.random.shuffle(idxs) + for idx in idxs: + sample_all = self.__getitem__(idx) + sample = [sample_all[f] for f in fields] + if has_empty(sample): + logger.info("sample field: %d has empty field"%len(sample)) + continue + batch_out.append(sample) + if len(batch_out) >= batch_size: + if cfg.RPN.ENABLED: + yield batch_out + else: + if self.mode == 'TRAIN': + yield self.padding_batch(batch_out, batch_size) + elif self.mode == 'EVAL': + # batch_size can should be 1 in rcnn_offline eval currently + # if batch_size > 1, batch should be padded as follow + # yield self.padding_batch_eval(batch_out, batch_size) + yield batch_out + else: + logger.error("not only support train/eval padding") + batch_out = [] + if not drop_last: + if len(batch_out) > 0: + yield batch_out + return reader + + def get_multiprocess_reader(self, batch_size, fields, proc_num=8, max_queue_len=128, drop_last=False): + def read_to_queue(idxs, queue): + for idx in idxs: + sample_all = self.__getitem__(idx) + sample = [sample_all[f] for f in fields] + queue.put(sample) + queue.put(None) + + def reader(): + sample_num = self.__len__() + idxs = np.arange(self.__len__()) + if self.mode == 'TRAIN': + np.random.shuffle(idxs) + + proc_idxs = [] + proc_sample_num = int(sample_num / proc_num) + start_idx = 0 + for i in range(proc_num - 1): + proc_idxs.append(idxs[start_idx:start_idx + proc_sample_num]) + start_idx += proc_sample_num + proc_idxs.append(idxs[start_idx:]) + + queue = multiprocessing.Queue(max_queue_len) + p_list = [] + for i in range(proc_num): + p_list.append(multiprocessing.Process( + target=read_to_queue, args=(proc_idxs[i], queue,))) + p_list[-1].start() + + finish_num = 0 + batch_out = [] + while finish_num < len(p_list): + sample = queue.get() + if sample is None: + finish_num += 1 + else: + batch_out.append(sample) + if len(batch_out) == batch_size: + yield batch_out + batch_out = [] + + # join process + for p in p_list: + if p.is_alive(): + p.join() + + return reader + diff --git a/PaddleCV/Paddle3D/PointRCNN/eval.py b/PaddleCV/Paddle3D/PointRCNN/eval.py new file mode 100644 index 00000000..7ee5d37f --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/eval.py @@ -0,0 +1,343 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import sys +import time +import shutil +import argparse +import logging +import multiprocessing +import numpy as np +from collections import OrderedDict +import paddle +import paddle.fluid as fluid + +from models.point_rcnn import PointRCNN +from data.kitti_rcnn_reader import KittiRCNNReader +from utils.run_utils import * +from utils.config import cfg, load_config, set_config_from_list +from utils.metric_utils import calc_iou_recall, rpn_metric, rcnn_metric + +logging.root.handlers = [] +FORMAT = '%(asctime)s-%(levelname)s: %(message)s' +logging.basicConfig(level=logging.INFO, format=FORMAT, stream=sys.stdout) +logger = logging.getLogger(__name__) + +np.random.seed(1024) # use same seed +METRIC_PROC_NUM = 4 + + +def parse_args(): + parser = argparse.ArgumentParser( + "PointRCNN semantic segmentation train script") + parser.add_argument( + '--cfg', + type=str, + default='cfgs/default.yml', + help='specify the config for training') + parser.add_argument( + '--eval_mode', + type=str, + default='rpn', + required=True, + help='specify the training mode') + parser.add_argument( + '--batch_size', + type=int, + default=1, + help='evaluation batch size, default 1') + parser.add_argument( + '--ckpt_dir', + type=str, + default='checkpoints/199', + help='specify a ckpt directory to be evaluated if needed') + parser.add_argument( + '--data_dir', + type=str, + default='./data', + help='KITTI dataset root directory') + parser.add_argument( + '--output_dir', + type=str, + default='output', + help='output directory') + parser.add_argument( + '--save_rpn_feature', + action='store_true', + default=False, + help='save features for separately rcnn training and evaluation') + parser.add_argument( + '--save_result', + action='store_true', + default=False, + help='save roi and refine result of evaluation') + parser.add_argument( + '--rcnn_eval_roi_dir', + type=str, + default=None, + help='specify the saved rois for rcnn evaluation when using rcnn_offline mode') + parser.add_argument( + '--rcnn_eval_feature_dir', + type=str, + default=None, + help='specify the saved features for rcnn evaluation when using rcnn_offline mode') + parser.add_argument( + '--log_interval', + type=int, + default=1, + help='mini-batch interval to log.') + parser.add_argument( + '--set', + dest='set_cfgs', + default=None, + nargs=argparse.REMAINDER, + help='set extra config keys if needed.') + args = parser.parse_args() + return args + + +def eval(): + args = parse_args() + print_arguments(args) + # check whether the installed paddle is compiled with GPU + # PointRCNN model can only run on GPU + check_gpu(True) + + load_config(args.cfg) + if args.set_cfgs is not None: + set_config_from_list(args.set_cfgs) + + if not os.path.isdir(args.output_dir): + os.makedirs(args.output_dir) + + if args.eval_mode == 'rpn': + cfg.RPN.ENABLED = True + cfg.RCNN.ENABLED = False + elif args.eval_mode == 'rcnn': + cfg.RCNN.ENABLED = True + cfg.RPN.ENABLED = cfg.RPN.FIXED = True + assert args.batch_size, "batch size must be 1 in rcnn evaluation" + elif args.eval_mode == 'rcnn_offline': + cfg.RCNN.ENABLED = True + cfg.RPN.ENABLED = False + assert args.batch_size, "batch size must be 1 in rcnn_offline evaluation" + else: + raise NotImplementedError("unkown eval mode: {}".format(args.eval_mode)) + + place = fluid.CUDAPlace(0) + exe = fluid.Executor(place) + + # build model + startup = fluid.Program() + eval_prog = fluid.Program() + with fluid.program_guard(eval_prog, startup): + with fluid.unique_name.guard(): + eval_model = PointRCNN(cfg, args.batch_size, True, 'TEST') + eval_model.build() + eval_pyreader = eval_model.get_pyreader() + eval_feeds = eval_model.get_feeds() + eval_outputs = eval_model.get_outputs() + eval_prog = eval_prog.clone(True) + + extra_keys = [] + if args.eval_mode == 'rpn': + extra_keys.extend(['sample_id', 'rpn_cls_label', 'gt_boxes3d']) + if args.save_rpn_feature: + extra_keys.extend(['pts_rect', 'pts_features', 'pts_input',]) + eval_keys, eval_values = parse_outputs( + eval_outputs, prog=eval_prog, extra_keys=extra_keys) + + eval_compile_prog = fluid.compiler.CompiledProgram( + eval_prog).with_data_parallel() + + exe.run(startup) + + # load checkpoint + assert os.path.isdir( + args.ckpt_dir), "ckpt_dir {} not a directory".format(args.ckpt_dir) + + def if_exist(var): + return os.path.exists(os.path.join(args.ckpt_dir, var.name)) + fluid.io.load_vars(exe, args.ckpt_dir, eval_prog, predicate=if_exist) + + kitti_feature_dir = os.path.join(args.output_dir, 'features') + kitti_output_dir = os.path.join(args.output_dir, 'detections', 'data') + seg_output_dir = os.path.join(args.output_dir, 'seg_result') + if args.save_rpn_feature: + if os.path.exists(kitti_feature_dir): + shutil.rmtree(kitti_feature_dir) + os.makedirs(kitti_feature_dir) + if os.path.exists(kitti_output_dir): + shutil.rmtree(kitti_output_dir) + os.makedirs(kitti_output_dir) + if os.path.exists(seg_output_dir): + shutil.rmtree(seg_output_dir) + os.makedirs(seg_output_dir) + + # must make sure these dirs existing + roi_output_dir = os.path.join('./result_dir', 'roi_result', 'data') + refine_output_dir = os.path.join('./result_dir', 'refine_result', 'data') + final_output_dir = os.path.join("./result_dir", 'final_result', 'data') + if not os.path.exists(final_output_dir): + os.makedirs(final_output_dir) + if args.save_result: + if not os.path.exists(roi_output_dir): + os.makedirs(roi_output_dir) + if not os.path.exists(refine_output_dir): + os.makedirs(refine_output_dir) + + # get reader + kitti_rcnn_reader = KittiRCNNReader(data_dir=args.data_dir, + npoints=cfg.RPN.NUM_POINTS, + split=cfg.TEST.SPLIT, + mode='EVAL', + classes=cfg.CLASSES, + rcnn_eval_roi_dir=args.rcnn_eval_roi_dir, + rcnn_eval_feature_dir=args.rcnn_eval_feature_dir) + eval_reader = kitti_rcnn_reader.get_multiprocess_reader(args.batch_size, eval_feeds) + eval_pyreader.decorate_sample_list_generator(eval_reader, place) + + thresh_list = [0.1, 0.3, 0.5, 0.7, 0.9] + queue = multiprocessing.Queue(128) + mgr = multiprocessing.Manager() + lock = multiprocessing.Lock() + mdict = mgr.dict() + if cfg.RPN.ENABLED: + mdict['exit_proc'] = 0 + mdict['total_gt_bbox'] = 0 + mdict['total_cnt'] = 0 + mdict['total_rpn_iou'] = 0 + for i in range(len(thresh_list)): + mdict['total_recalled_bbox_list_{}'.format(i)] = 0 + + p_list = [] + for i in range(METRIC_PROC_NUM): + p_list.append(multiprocessing.Process( + target=rpn_metric, + args=(queue, mdict, lock, thresh_list, args.save_rpn_feature, kitti_feature_dir, + seg_output_dir, kitti_output_dir, kitti_rcnn_reader, cfg.CLASSES))) + p_list[-1].start() + + if cfg.RCNN.ENABLED: + for i in range(len(thresh_list)): + mdict['total_recalled_bbox_list_{}'.format(i)] = 0 + mdict['total_roi_recalled_bbox_list_{}'.format(i)] = 0 + mdict['exit_proc'] = 0 + mdict['total_cls_acc'] = 0 + mdict['total_cls_acc_refined'] = 0 + mdict['total_det_num'] = 0 + mdict['total_gt_bbox'] = 0 + p_list = [] + for i in range(METRIC_PROC_NUM): + p_list.append(multiprocessing.Process( + target=rcnn_metric, + args=(queue, mdict, lock, thresh_list, kitti_rcnn_reader, roi_output_dir, + refine_output_dir, final_output_dir, args.save_result) + )) + p_list[-1].start() + + try: + eval_pyreader.start() + eval_iter = 0 + start_time = time.time() + + cur_time = time.time() + while True: + eval_outs = exe.run(eval_compile_prog, fetch_list=eval_values, return_numpy=False) + rets_dict = {k: (np.array(v), v.recursive_sequence_lengths()) + for k, v in zip(eval_keys, eval_outs)} + run_time = time.time() - cur_time + cur_time = time.time() + queue.put(rets_dict) + eval_iter += 1 + + logger.info("[EVAL] iter {}, time: {:.2f}".format( + eval_iter, run_time)) + + except fluid.core.EOFException: + # terminate metric process + for i in range(METRIC_PROC_NUM): + queue.put(None) + while mdict['exit_proc'] < METRIC_PROC_NUM: + time.sleep(1) + for p in p_list: + if p.is_alive(): + p.join() + + end_time = time.time() + logger.info("[EVAL] total {} iter finished, average time: {:.2f}".format( + eval_iter, (end_time - start_time) / float(eval_iter))) + + if cfg.RPN.ENABLED: + avg_rpn_iou = mdict['total_rpn_iou'] / max(len(kitti_rcnn_reader), 1.) + logger.info("average rpn iou: {:.3f}".format(avg_rpn_iou)) + total_gt_bbox = float(max(mdict['total_gt_bbox'], 1.0)) + for idx, thresh in enumerate(thresh_list): + recall = mdict['total_recalled_bbox_list_{}'.format(idx)] / total_gt_bbox + logger.info("total bbox recall(thresh={:.3f}): {} / {} = {:.3f}".format( + thresh, mdict['total_recalled_bbox_list_{}'.format(idx)], mdict['total_gt_bbox'], recall)) + + if cfg.RCNN.ENABLED: + cnt = float(max(eval_iter, 1.0)) + avg_cls_acc = mdict['total_cls_acc'] / cnt + avg_cls_acc_refined = mdict['total_cls_acc_refined'] / cnt + avg_det_num = mdict['total_det_num'] / cnt + + logger.info("avg_cls_acc: {}".format(avg_cls_acc)) + logger.info("avg_cls_acc_refined: {}".format(avg_cls_acc_refined)) + logger.info("avg_det_num: {}".format(avg_det_num)) + + total_gt_bbox = float(max(mdict['total_gt_bbox'], 1.0)) + for idx, thresh in enumerate(thresh_list): + cur_roi_recall = mdict['total_roi_recalled_bbox_list_{}'.format(idx)] / total_gt_bbox + logger.info('total roi bbox recall(thresh=%.3f): %d / %d = %f' % ( + thresh, mdict['total_roi_recalled_bbox_list_{}'.format(idx)], total_gt_bbox, cur_roi_recall)) + + for idx, thresh in enumerate(thresh_list): + cur_recall = mdict['total_recalled_bbox_list_{}'.format(idx)] / total_gt_bbox + logger.info('total bbox recall(thresh=%.2f) %d / %.2f = %.4f' % ( + thresh, mdict['total_recalled_bbox_list_{}'.format(idx)], total_gt_bbox, cur_recall)) + + split_file = os.path.join('./data/KITTI', 'ImageSets', 'val.txt') + image_idx_list = [x.strip() for x in open(split_file).readlines()] + for k in range(image_idx_list.__len__()): + cur_file = os.path.join(final_output_dir, '%s.txt' % image_idx_list[k]) + if not os.path.exists(cur_file): + with open(cur_file, 'w') as temp_f: + pass + + if float(sys.version[:3]) >= 3.6: + label_dir = os.path.join('./data/KITTI/object/training', 'label_2') + split_file = os.path.join('./data/KITTI', 'ImageSets', 'val.txt') + final_output_dir = os.path.join("./result_dir", 'final_result', 'data') + name_to_class = {'Car': 0, 'Pedestrian': 1, 'Cyclist': 2} + + from tools.kitti_object_eval_python.evaluate import evaluate as kitti_evaluate + ap_result_str, ap_dict = kitti_evaluate( + label_dir, final_output_dir, label_split_file=split_file, + current_class=name_to_class["Car"]) + + logger.info("KITTI evaluate: {}, {}".format(ap_result_str, ap_dict)) + + else: + logger.info("KITTI mAP only support python version >= 3.6, users can " + "run 'python3 tools/kitti_eval.py' to evaluate KITTI mAP.") + + finally: + eval_pyreader.reset() + + +if __name__ == "__main__": + eval() diff --git a/PaddleCV/Paddle3D/PointRCNN/ext_op b/PaddleCV/Paddle3D/PointRCNN/ext_op new file mode 120000 index 00000000..dca99c67 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/ext_op @@ -0,0 +1 @@ +../PointNet++/ext_op \ No newline at end of file diff --git a/PaddleCV/Paddle3D/PointRCNN/images/teaser.png b/PaddleCV/Paddle3D/PointRCNN/images/teaser.png new file mode 100644 index 0000000000000000000000000000000000000000..21ae7e98165074ef93dc34fc643b3fddc5fe6c36 GIT binary patch literal 332341 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

vomGPOsj2t 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⁡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

VB;r?R_>8K)>IR{{3=gB?x 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

G9y#QX9{uYEsy|_! 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

l` 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 Y=12xAu}}nhA>d0+TIortlu7Xa0Xs$J_5c6? literal 0 HcmV?d00001 diff --git a/PaddleCV/Paddle3D/PointRCNN/models/__init__.py b/PaddleCV/Paddle3D/PointRCNN/models/__init__.py new file mode 100644 index 00000000..46a4f6ee --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/models/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +#Licensed under the Apache License, Version 2.0 (the "License"); +#you may not use this file except in compliance with the License. +#You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +#Unless required by applicable law or agreed to in writing, software +#distributed under the License is distributed on an "AS IS" BASIS, +#WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#See the License for the specific language governing permissions and +#limitations under the License. diff --git a/PaddleCV/Paddle3D/PointRCNN/models/loss_utils.py b/PaddleCV/Paddle3D/PointRCNN/models/loss_utils.py new file mode 100644 index 00000000..04db2398 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/models/loss_utils.py @@ -0,0 +1,201 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +#Licensed under the Apache License, Version 2.0 (the "License"); +#you may not use this file except in compliance with the License. +#You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +#Unless required by applicable law or agreed to in writing, software +#distributed under the License is distributed on an "AS IS" BASIS, +#WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#See the License for the specific language governing permissions and +#limitations under the License. + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import numpy as np + +import paddle.fluid as fluid +from paddle.fluid.param_attr import ParamAttr +from paddle.fluid.initializer import Constant + +__all__ = ["get_reg_loss"] + + +def sigmoid_focal_loss(logits, labels, weights, gamma=2.0, alpha=0.25): + sce_loss = fluid.layers.sigmoid_cross_entropy_with_logits(logits, labels) + prob = fluid.layers.sigmoid(logits) + p_t = labels * prob + (1.0 - labels) * (1.0 - prob) + modulating_factor = fluid.layers.pow(1.0 - p_t, gamma) + alpha_weight_factor = labels * alpha + (1.0 - labels) * (1.0 - alpha) + return modulating_factor * alpha_weight_factor * sce_loss * weights + + +def get_reg_loss(pred_reg, reg_label, fg_mask, point_num, loc_scope, + loc_bin_size, num_head_bin, anchor_size, + get_xz_fine=True, get_y_by_bin=False, loc_y_scope=0.5, + loc_y_bin_size=0.25, get_ry_fine=False): + + """ + Bin-based 3D bounding boxes regression loss. See https://arxiv.org/abs/1812.04244 for more details. + + :param pred_reg: (N, C) + :param reg_label: (N, 7) [dx, dy, dz, h, w, l, ry] + :param loc_scope: constant + :param loc_bin_size: constant + :param num_head_bin: constant + :param anchor_size: (N, 3) or (3) + :param get_xz_fine: + :param get_y_by_bin: + :param loc_y_scope: + :param loc_y_bin_size: + :param get_ry_fine: + :return: + """ + fg_num = fluid.layers.cast(fluid.layers.reduce_sum(fg_mask), dtype=pred_reg.dtype) + fg_num = fluid.layers.clip(fg_num, min=1.0, max=point_num) + fg_scale = float(point_num) / fg_num + + per_loc_bin_num = int(loc_scope / loc_bin_size) * 2 + loc_y_bin_num = int(loc_y_scope / loc_y_bin_size) * 2 + + reg_loss_dict = {} + + # xz localization loss + x_offset_label, y_offset_label, z_offset_label = reg_label[:, 0:1], reg_label[:, 1:2], reg_label[:, 2:3] + x_shift = fluid.layers.clip(x_offset_label + loc_scope, 0., loc_scope * 2 - 1e-3) + z_shift = fluid.layers.clip(z_offset_label + loc_scope, 0., loc_scope * 2 - 1e-3) + x_bin_label = fluid.layers.cast(x_shift / loc_bin_size, dtype='int64') + z_bin_label = fluid.layers.cast(z_shift / loc_bin_size, dtype='int64') + + x_bin_l, x_bin_r = 0, per_loc_bin_num + z_bin_l, z_bin_r = per_loc_bin_num, per_loc_bin_num * 2 + start_offset = z_bin_r + + loss_x_bin = fluid.layers.softmax_with_cross_entropy(pred_reg[:, x_bin_l: x_bin_r], x_bin_label) + loss_x_bin = fluid.layers.reduce_mean(loss_x_bin * fg_mask) * fg_scale + loss_z_bin = fluid.layers.softmax_with_cross_entropy(pred_reg[:, z_bin_l: z_bin_r], z_bin_label) + loss_z_bin = fluid.layers.reduce_mean(loss_z_bin * fg_mask) * fg_scale + reg_loss_dict['loss_x_bin'] = loss_x_bin + reg_loss_dict['loss_z_bin'] = loss_z_bin + loc_loss = loss_x_bin + loss_z_bin + + if get_xz_fine: + x_res_l, x_res_r = per_loc_bin_num * 2, per_loc_bin_num * 3 + z_res_l, z_res_r = per_loc_bin_num * 3, per_loc_bin_num * 4 + start_offset = z_res_r + + x_res_label = x_shift - (fluid.layers.cast(x_bin_label, dtype=x_shift.dtype) * loc_bin_size + loc_bin_size / 2.) + z_res_label = z_shift - (fluid.layers.cast(z_bin_label, dtype=z_shift.dtype) * loc_bin_size + loc_bin_size / 2.) + x_res_norm_label = x_res_label / loc_bin_size + z_res_norm_label = z_res_label / loc_bin_size + + x_bin_onehot = fluid.layers.one_hot(x_bin_label, depth=per_loc_bin_num) + z_bin_onehot = fluid.layers.one_hot(z_bin_label, depth=per_loc_bin_num) + + loss_x_res = fluid.layers.smooth_l1(fluid.layers.reduce_sum(pred_reg[:, x_res_l: x_res_r] * x_bin_onehot, dim=1, keep_dim=True), x_res_norm_label) + loss_x_res = fluid.layers.reduce_mean(loss_x_res * fg_mask) * fg_scale + loss_z_res = fluid.layers.smooth_l1(fluid.layers.reduce_sum(pred_reg[:, z_res_l: z_res_r] * z_bin_onehot, dim=1, keep_dim=True), z_res_norm_label) + loss_z_res = fluid.layers.reduce_mean(loss_z_res * fg_mask) * fg_scale + reg_loss_dict['loss_x_res'] = loss_x_res + reg_loss_dict['loss_z_res'] = loss_z_res + loc_loss += loss_x_res + loss_z_res + + # y localization loss + if get_y_by_bin: + y_bin_l, y_bin_r = start_offset, start_offset + loc_y_bin_num + y_res_l, y_res_r = y_bin_r, y_bin_r + loc_y_bin_num + start_offset = y_res_r + + y_shift = fluid.layers.clip(y_offset_label + loc_y_scope, 0., loc_y_scope * 2 - 1e-3) + y_bin_label = fluid.layers.cast(y_shift / loc_y_bin_size, dtype='int64') + y_res_label = y_shift - (fluid.layers.cast(y_bin_label, dtype=y_shift.dtype) * loc_y_bin_size + loc_y_bin_size / 2.) + y_res_norm_label = y_res_label / loc_y_bin_size + + y_bin_onehot = fluid.layers.one_hot(y_bin_label, depth=per_loc_bin_num) + + loss_y_bin = fluid.layers.cross_entropy(pred_reg[:, y_bin_l: y_bin_r], y_bin_label) + loss_y_bin = fluid.layers.reduce_mean(loss_y_bin * fg_mask) * fg_scale + loss_y_res = fluid.layers.smooth_l1(fluid.layers.reduce_sum(pred_reg[:, y_res_l: y_res_r] * y_bin_onehot, dim=1, keep_dim=True), y_res_norm_label) + loss_y_res = fluid.layers.reduce_mean(loss_y_res * fg_mask) * fg_scale + + reg_loss_dict['loss_y_bin'] = loss_y_bin + reg_loss_dict['loss_y_res'] = loss_y_res + + loc_loss += loss_y_bin + loss_y_res + else: + y_offset_l, y_offset_r = start_offset, start_offset + 1 + start_offset = y_offset_r + + loss_y_offset = fluid.layers.smooth_l1(fluid.layers.reduce_sum(pred_reg[:, y_offset_l: y_offset_r], dim=1, keep_dim=True), y_offset_label) + loss_y_offset = fluid.layers.reduce_mean(loss_y_offset * fg_mask) * fg_scale + reg_loss_dict['loss_y_offset'] = loss_y_offset + loc_loss += loss_y_offset + + # angle loss + ry_bin_l, ry_bin_r = start_offset, start_offset + num_head_bin + ry_res_l, ry_res_r = ry_bin_r, ry_bin_r + num_head_bin + + ry_label = reg_label[:, 6:7] + + if get_ry_fine: + # divide pi/2 into several bins + angle_per_class = (np.pi / 2) / num_head_bin + + ry_label = ry_label % (2 * np.pi) # 0 ~ 2pi + opposite_flag = fluid.layers.logical_and(ry_label > np.pi * 0.5, ry_label < np.pi * 1.5) + opposite_flag = fluid.layers.cast(opposite_flag, dtype=ry_label.dtype) + shift_angle = (ry_label + opposite_flag * np.pi + np.pi * 0.5) % (2 * np.pi) # (0 ~ pi) + shift_angle.stop_gradient = True + + shift_angle = fluid.layers.clip(shift_angle - np.pi * 0.25, min=1e-3, max=np.pi * 0.5 - 1e-3) # (0, pi/2) + + # bin center is (5, 10, 15, ..., 85) + ry_bin_label = fluid.layers.cast(shift_angle / angle_per_class, dtype='int64') + ry_res_label = shift_angle - (fluid.layers.cast(ry_bin_label, dtype=shift_angle.dtype) * angle_per_class + angle_per_class / 2) + ry_res_norm_label = ry_res_label / (angle_per_class / 2) + + else: + # divide 2pi into several bins + angle_per_class = (2 * np.pi) / num_head_bin + heading_angle = ry_label % (2 * np.pi) # 0 ~ 2pi + + shift_angle = (heading_angle + angle_per_class / 2) % (2 * np.pi) + shift_angle.stop_gradient = True + ry_bin_label = fluid.layers.cast(shift_angle / angle_per_class, dtype='int64') + ry_res_label = shift_angle - (fluid.layers.cast(ry_bin_label, dtype=shift_angle.dtype) * angle_per_class + angle_per_class / 2) + ry_res_norm_label = ry_res_label / (angle_per_class / 2) + + ry_bin_onehot = fluid.layers.one_hot(ry_bin_label, depth=num_head_bin) + loss_ry_bin = fluid.layers.softmax_with_cross_entropy(pred_reg[:, ry_bin_l:ry_bin_r], ry_bin_label) + loss_ry_bin = fluid.layers.reduce_mean(loss_ry_bin * fg_mask) * fg_scale + loss_ry_res = fluid.layers.smooth_l1(fluid.layers.reduce_sum(pred_reg[:, ry_res_l: ry_res_r] * ry_bin_onehot, dim=1, keep_dim=True), ry_res_norm_label) + loss_ry_res = fluid.layers.reduce_mean(loss_ry_res * fg_mask) * fg_scale + + reg_loss_dict['loss_ry_bin'] = loss_ry_bin + reg_loss_dict['loss_ry_res'] = loss_ry_res + angle_loss = loss_ry_bin + loss_ry_res + + # size loss + size_res_l, size_res_r = ry_res_r, ry_res_r + 3 + assert pred_reg.shape[1] == size_res_r, '%d vs %d' % (pred_reg.shape[1], size_res_r) + + anchor_size_var = fluid.layers.zeros(shape=[3], dtype=reg_label.dtype) + fluid.layers.assign(np.array(anchor_size).astype('float32'), anchor_size_var) + size_res_norm_label = (reg_label[:, 3:6] - anchor_size_var) / anchor_size_var + size_res_norm_label = fluid.layers.reshape(size_res_norm_label, shape=[-1, 1], inplace=True) + size_res_norm = pred_reg[:, size_res_l:size_res_r] + size_res_norm = fluid.layers.reshape(size_res_norm, shape=[-1, 1], inplace=True) + size_loss = fluid.layers.smooth_l1(size_res_norm, size_res_norm_label) + size_loss = fluid.layers.reduce_mean(fluid.layers.reshape(size_loss, [-1, 3]) * fg_mask) * fg_scale + + # Total regression loss + reg_loss_dict['loss_loc'] = loc_loss + reg_loss_dict['loss_angle'] = angle_loss + reg_loss_dict['loss_size'] = size_loss + + return loc_loss, angle_loss, size_loss, reg_loss_dict + diff --git a/PaddleCV/Paddle3D/PointRCNN/models/point_rcnn.py b/PaddleCV/Paddle3D/PointRCNN/models/point_rcnn.py new file mode 100644 index 00000000..890ef897 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/models/point_rcnn.py @@ -0,0 +1,125 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +#Licensed under the Apache License, Version 2.0 (the "License"); +#you may not use this file except in compliance with the License. +#You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +#Unless required by applicable law or agreed to in writing, software +#distributed under the License is distributed on an "AS IS" BASIS, +#WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#See the License for the specific language governing permissions and +#limitations under the License. +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import numpy as np +from collections import OrderedDict + +import paddle.fluid as fluid +from paddle.fluid.param_attr import ParamAttr +from paddle.fluid.initializer import Constant + +from models.rpn import RPN +from models.rcnn import RCNN + + +__all__ = ["PointRCNN"] + + +class PointRCNN(object): + def __init__(self, cfg, batch_size, use_xyz=True, mode='TRAIN', prog=None): + self.cfg = cfg + self.batch_size = batch_size + self.use_xyz = use_xyz + self.mode = mode + self.is_train = mode == 'TRAIN' + self.num_points = self.cfg.RPN.NUM_POINTS + self.prog = prog + self.inputs = None + self.pyreader = None + + def build_inputs(self): + self.inputs = OrderedDict() + + if self.cfg.RPN.ENABLED: + self.inputs['sample_id'] = fluid.layers.data(name='sample_id', shape=[1], dtype='int32') + self.inputs['pts_input'] = fluid.layers.data(name='pts_input', shape=[self.num_points, 3], dtype='float32') + self.inputs['pts_rect'] = fluid.layers.data(name='pts_rect', shape=[self.num_points, 3], dtype='float32') + self.inputs['pts_features'] = fluid.layers.data(name='pts_features', shape=[self.num_points, 1], dtype='float32') + self.inputs['rpn_cls_label'] = fluid.layers.data(name='rpn_cls_label', shape=[self.num_points], dtype='int32') + self.inputs['rpn_reg_label'] = fluid.layers.data(name='rpn_reg_label', shape=[self.num_points, 7], dtype='float32') + self.inputs['gt_boxes3d'] = fluid.layers.data(name='gt_boxes3d', shape=[7], lod_level=1, dtype='float32') + + if self.cfg.RCNN.ENABLED: + if self.cfg.RCNN.ROI_SAMPLE_JIT: + self.inputs['sample_id'] = fluid.layers.data(name='sample_id', shape=[1], dtype='int32', append_batch_size=False) + self.inputs['rpn_xyz'] = fluid.layers.data(name='rpn_xyz', shape=[self.num_points, 3], dtype='float32', append_batch_size=False) + self.inputs['rpn_features'] = fluid.layers.data(name='rpn_features', shape=[self.num_points,128], dtype='float32', append_batch_size=False) + self.inputs['rpn_intensity'] = fluid.layers.data(name='rpn_intensity', shape=[self.num_points], dtype='float32', append_batch_size=False) + self.inputs['seg_mask'] = fluid.layers.data(name='seg_mask', shape=[self.num_points], dtype='float32', append_batch_size=False) + self.inputs['roi_boxes3d'] = fluid.layers.data(name='roi_boxes3d', shape=[-1, -1, 7], dtype='float32', append_batch_size=False, lod_level=0) + self.inputs['pts_depth'] = fluid.layers.data(name='pts_depth', shape=[self.num_points], dtype='float32', append_batch_size=False) + self.inputs['gt_boxes3d'] = fluid.layers.data(name='gt_boxes3d', shape=[-1, -1, 7], dtype='float32', append_batch_size=False, lod_level=0) + else: + self.inputs['sample_id'] = fluid.layers.data(name='sample_id', shape=[-1], dtype='int32', append_batch_size=False) + self.inputs['pts_input'] = fluid.layers.data(name='pts_input', shape=[-1,512,133], dtype='float32', append_batch_size=False) + self.inputs['pts_feature'] = fluid.layers.data(name='pts_feature', shape=[-1,512,128], dtype='float32', append_batch_size=False) + self.inputs['roi_boxes3d'] = fluid.layers.data(name='roi_boxes3d', shape=[-1,7], dtype='float32', append_batch_size=False) + if self.is_train: + self.inputs['cls_label'] = fluid.layers.data(name='cls_label', shape=[-1], dtype='float32', append_batch_size=False) + self.inputs['reg_valid_mask'] = fluid.layers.data(name='reg_valid_mask', shape=[-1], dtype='float32', append_batch_size=False) + self.inputs['gt_boxes3d_ct'] = fluid.layers.data(name='gt_boxes3d_ct', shape=[-1,7], dtype='float32', append_batch_size=False) + self.inputs['gt_of_rois'] = fluid.layers.data(name='gt_of_rois', shape=[-1,7], dtype='float32', append_batch_size=False) + else: + self.inputs['roi_scores'] = fluid.layers.data(name='roi_scores', shape=[-1,], dtype='float32', append_batch_size=False) + self.inputs['gt_iou'] = fluid.layers.data(name='gt_iou', shape=[-1], dtype='float32', append_batch_size=False) + self.inputs['gt_boxes3d'] = fluid.layers.data(name='gt_boxes3d', shape=[-1,-1,7], dtype='float32', append_batch_size=False, lod_level=0) + + + self.pyreader = fluid.io.PyReader( + feed_list=list(self.inputs.values()), + capacity=64, + use_double_buffer=True, + iterable=False) + + def build(self): + self.build_inputs() + if self.cfg.RPN.ENABLED: + self.rpn = RPN(self.cfg, self.batch_size, self.use_xyz, + self.mode, self.prog) + self.rpn.build(self.inputs) + self.rpn_outputs = self.rpn.get_outputs() + self.outputs = self.rpn_outputs + + if self.cfg.RCNN.ENABLED: + self.rcnn = RCNN(self.cfg, 1, self.batch_size, self.mode) + self.rcnn.build_model(self.inputs) + self.outputs = self.rcnn.get_outputs() + + if self.mode == 'TRAIN': + if self.cfg.RPN.ENABLED: + self.outputs['rpn_loss'], self.outputs['rpn_loss_cls'], \ + self.outputs['rpn_loss_reg'] = self.rpn.get_loss() + if self.cfg.RCNN.ENABLED: + self.outputs['rcnn_loss'], self.outputs['rcnn_loss_cls'], \ + self.outputs['rcnn_loss_reg'] = self.rcnn.get_loss() + self.outputs['loss'] = self.outputs.get('rpn_loss', 0.) \ + + self.outputs.get('rcnn_loss', 0.) + + def get_feeds(self): + return list(self.inputs.keys()) + + def get_outputs(self): + return self.outputs + + def get_loss(self): + rpn_loss, _, _ = self.rpn.get_loss() + rcnn_loss, _, _ = self.rcnn.get_loss() + return rpn_loss + rcnn_loss + + def get_pyreader(self): + return self.pyreader + diff --git a/PaddleCV/Paddle3D/PointRCNN/models/pointnet2_modules.py b/PaddleCV/Paddle3D/PointRCNN/models/pointnet2_modules.py new file mode 100644 index 00000000..43942fcf --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/models/pointnet2_modules.py @@ -0,0 +1,197 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +#Licensed under the Apache License, Version 2.0 (the "License"); +#you may not use this file except in compliance with the License. +#You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +#Unless required by applicable law or agreed to in writing, software +#distributed under the License is distributed on an "AS IS" BASIS, +#WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#See the License for the specific language governing permissions and +#limitations under the License. +""" +Contains PointNet++ utility functions. +""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import numpy as np + +import paddle.fluid as fluid +from paddle.fluid.param_attr import ParamAttr +from paddle.fluid.initializer import Constant +from ext_op import * + +__all__ = ["conv_bn", "pointnet_sa_module", "pointnet_fp_module", "MLP"] + + +def query_and_group(xyz, new_xyz, radius, nsample, features=None, use_xyz=True): + """ + Perform query_ball and group_points + + Args: + xyz (Variable): xyz coordiantes features with shape [B, N, 3] + new_xyz (Variable): centriods features with shape [B, npoint, 3] + radius (float32): radius of ball + nsample (int32): maximum number of gather features + features (Variable): features with shape [B, N, C] + use_xyz (bool): whether use xyz coordiantes features + + Returns: + out (Variable): features with shape [B, npoint, nsample, C + 3] + """ + idx = query_ball(xyz, new_xyz, radius, nsample) + idx.stop_gradient = True + xyz = fluid.layers.transpose(xyz,perm=[0, 2, 1]) + grouped_xyz = group_points(xyz, idx) + expand_new_xyz = fluid.layers.unsqueeze(fluid.layers.transpose(new_xyz, perm=[0, 2, 1]), axes=[-1]) + expand_new_xyz = fluid.layers.expand(expand_new_xyz, [1, 1, 1, grouped_xyz.shape[3]]) + grouped_xyz -= expand_new_xyz + + if features is not None: + grouped_features = group_points(features, idx) + return fluid.layers.concat([grouped_xyz, grouped_features], axis=1) \ + if use_xyz else grouped_features + else: + assert use_xyz, "use_xyz should be True when features is None" + return grouped_xyz + + +def group_all(xyz, features=None, use_xyz=True): + """ + Group all xyz and features when npoint is None + See query_and_group + """ + xyz = fluid.layers.transpose(xyz,perm=[0, 2, 1]) + grouped_xyz = fluid.layers.unsqueeze(xyz, axes=[2]) + if features is not None: + grouped_features = fluid.layers.unsqueeze(features, axes=[2]) + return fluid.layers.concat([grouped_xyz, grouped_features], axis=1) if use_xyz else grouped_features + else: + return grouped_xyz + + +def conv_bn(input, out_channels, bn=True, bn_momentum=0.95, act='relu', name=None): + param_attr = ParamAttr(name='{}_conv_weight'.format(name),) + bias_attr = ParamAttr(name='{}_conv_bias'.format(name)) \ + if not bn else False + out = fluid.layers.conv2d(input, + num_filters=out_channels, + filter_size=1, + stride=1, + padding=0, + dilation=1, + param_attr=param_attr, + bias_attr=bias_attr, + act=act if not bn else None) + if bn: + bn_name = name + "_bn" + out = fluid.layers.batch_norm(out, + act=act, + momentum=bn_momentum, + param_attr=ParamAttr(name=bn_name + "_scale"), + bias_attr=ParamAttr(name=bn_name + "_offset"), + moving_mean_name=bn_name + '_mean', + moving_variance_name=bn_name + '_var') + + return out + + +def MLP(features, out_channels_list, bn=True, bn_momentum=0.95, act='relu', name=None): + out = features + for i, out_channels in enumerate(out_channels_list): + out = conv_bn(out, out_channels, bn=bn, act=act, bn_momentum=bn_momentum, name=name + "_{}".format(i)) + return out + + +def pointnet_sa_module(xyz, + npoint=None, + radiuss=[], + nsamples=[], + mlps=[], + feature=None, + bn=True, + bn_momentum=0.95, + use_xyz=True, + name=None): + """ + PointNet MSG(Multi-Scale Group) Set Abstraction Module. + Call with radiuss, nsamples, mlps as single element list for + SSG(Single-Scale Group). + + Args: + xyz (Variable): xyz coordiantes features with shape [B, N, 3] + radiuss ([float32]): list of radius of ball + nsamples ([int32]): list of maximum number of gather features + mlps ([[int32]]): list of out_channels_list + feature (Variable): features with shape [B, C, N] + bn (bool): whether perform batch norm after conv2d + bn_momentum (float): momentum of batch norm + use_xyz (bool): whether use xyz coordiantes features + + Returns: + new_xyz (Variable): centriods features with shape [B, npoint, 3] + out (Variable): features with shape [B, npoint, \sum_i{mlps[i][-1]}] + """ + assert len(radiuss) == len(nsamples) == len(mlps), \ + "radiuss, nsamples, mlps length should be same" + + farthest_idx = farthest_point_sampling(xyz, npoint) + farthest_idx.stop_gradient = True + new_xyz = gather_point(xyz, farthest_idx) if npoint is not None else None + + outs = [] + for i, (radius, nsample, mlp) in enumerate(zip(radiuss, nsamples, mlps)): + out = query_and_group(xyz, new_xyz, radius, nsample, feature, use_xyz) if npoint is not None else group_all(xyz, feature, use_xyz) + out = MLP(out, mlp, bn=bn, bn_momentum=bn_momentum, name=name + '_mlp{}'.format(i)) + out = fluid.layers.pool2d(out, pool_size=[1, out.shape[3]], pool_type='max') + out = fluid.layers.squeeze(out, axes=[-1]) + outs.append(out) + out = fluid.layers.concat(outs, axis=1) + + return (new_xyz, out) + + +def pointnet_fp_module(unknown, known, unknown_feats, known_feats, mlp, bn=True, bn_momentum=0.95, name=None): + """ + PointNet Feature Propagation Module + + Args: + unknown (Variable): unknown xyz coordiantes features with shape [B, N, 3] + known (Variable): known xyz coordiantes features with shape [B, M, 3] + unknown_feats (Variable): unknown features with shape [B, N, C1] to be propagated to + known_feats (Variable): known features with shape [B, M, C2] to be propagated from + mlp ([int32]): out_channels_list + bn (bool): whether perform batch norm after conv2d + + Returns: + new_features (Variable): new features with shape [B, N, mlp[-1]] + """ + if known is None: + raise NotImplementedError("Not implement known as None currently.") + else: + dist, idx = three_nn(unknown, known, eps=0.) + dist.stop_gradient = True + idx.stop_gradient = True + dist = fluid.layers.sqrt(dist) + ones = fluid.layers.fill_constant_batch_size_like(dist, dist.shape, dist.dtype, 1) + dist_recip = ones / (dist + 1e-8); # 1.0 / dist + norm = fluid.layers.reduce_sum(dist_recip, dim=-1, keep_dim=True) + weight = dist_recip / norm + weight.stop_gradient = True + interp_feats = three_interp(known_feats, weight, idx) + + new_features = interp_feats if unknown_feats is None else \ + fluid.layers.concat([interp_feats, unknown_feats], axis=-1) + new_features = fluid.layers.transpose(new_features, perm=[0, 2, 1]) + new_features = fluid.layers.unsqueeze(new_features, axes=[-1]) + new_features = MLP(new_features, mlp, bn=bn, bn_momentum=bn_momentum, name=name + '_mlp') + new_features = fluid.layers.squeeze(new_features, axes=[-1]) + new_features = fluid.layers.transpose(new_features, perm=[0, 2, 1]) + + return new_features + diff --git a/PaddleCV/Paddle3D/PointRCNN/models/pointnet2_msg.py b/PaddleCV/Paddle3D/PointRCNN/models/pointnet2_msg.py new file mode 100644 index 00000000..b4d5f98c --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/models/pointnet2_msg.py @@ -0,0 +1,78 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +#Licensed under the Apache License, Version 2.0 (the "License"); +#you may not use this file except in compliance with the License. +#You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +#Unless required by applicable law or agreed to in writing, software +#distributed under the License is distributed on an "AS IS" BASIS, +#WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#See the License for the specific language governing permissions and +#limitations under the License. +""" +Contains PointNet++ SSG/MSG semantic segmentation models +""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import numpy as np + +import paddle.fluid as fluid +from paddle.fluid.param_attr import ParamAttr +from paddle.fluid.initializer import Constant +from models.pointnet2_modules import * + +__all__ = ["PointNet2MSG"] + + +class PointNet2MSG(object): + def __init__(self, cfg, xyz, feature=None, use_xyz=True): + self.cfg = cfg + self.xyz = xyz + self.feature = feature + self.use_xyz = use_xyz + self.model_config() + + def model_config(self): + self.SA_confs = [] + for i in range(self.cfg.RPN.SA_CONFIG.NPOINTS.__len__()): + self.SA_confs.append({ + "npoint": self.cfg.RPN.SA_CONFIG.NPOINTS[i], + "radiuss": self.cfg.RPN.SA_CONFIG.RADIUS[i], + "nsamples": self.cfg.RPN.SA_CONFIG.NSAMPLE[i], + "mlps": self.cfg.RPN.SA_CONFIG.MLPS[i], + }) + + self.FP_confs = [] + for i in range(self.cfg.RPN.FP_MLPS.__len__()): + self.FP_confs.append({"mlp": self.cfg.RPN.FP_MLPS[i]}) + + def build(self, bn_momentum=0.95): + xyzs, features = [self.xyz], [self.feature] + xyzi, featurei = self.xyz, self.feature + for i, SA_conf in enumerate(self.SA_confs): + xyzi, featurei = pointnet_sa_module( + xyz=xyzi, + feature=featurei, + bn_momentum=bn_momentum, + use_xyz=self.use_xyz, + name="sa_{}".format(i), + **SA_conf) + xyzs.append(xyzi) + features.append(fluid.layers.transpose(featurei, perm=[0, 2, 1])) + for i in range(-1, -(len(self.FP_confs) + 1), -1): + features[i - 1] = pointnet_fp_module( + unknown=xyzs[i - 1], + known=xyzs[i], + unknown_feats=features[i - 1], + known_feats=features[i], + bn_momentum=bn_momentum, + name="fp_{}".format(i + len(self.FP_confs)), + **self.FP_confs[i]) + + return xyzs[0], features[0] + diff --git a/PaddleCV/Paddle3D/PointRCNN/models/rcnn.py b/PaddleCV/Paddle3D/PointRCNN/models/rcnn.py new file mode 100644 index 00000000..11247eb4 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/models/rcnn.py @@ -0,0 +1,302 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +#Licensed under the Apache License, Version 2.0 (the "License"); +#you may not use this file except in compliance with the License. +#You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +#Unless required by applicable law or agreed to in writing, software +#distributed under the License is distributed on an "AS IS" BASIS, +#WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#See the License for the specific language governing permissions and +#limitations under the License. + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import numpy as np +import sys + +import paddle.fluid as fluid +from paddle.fluid.param_attr import ParamAttr +from paddle.fluid.initializer import Constant + +from models.pointnet2_modules import MLP, pointnet_sa_module, conv_bn +from models.loss_utils import sigmoid_focal_loss , get_reg_loss +from utils.proposal_target import get_proposal_target_func +from utils.cyops.kitti_utils import rotate_pc_along_y + +__all__ = ['RCNN'] + + +class RCNN(object): + def __init__(self, cfg, num_classes, batch_size, mode='TRAIN', use_xyz=True, input_channels=0): + self.cfg = cfg + self.use_xyz = use_xyz + self.num_classes = num_classes + self.input_channels = input_channels + self.inputs = None + self.training = mode == 'TRAIN' + self.batch_size = batch_size + + def create_tmp_var(self, name, dtype, shape): + return fluid.default_main_program().current_block().create_var( + name=name, dtype=dtype, shape=shape + ) + + def build_model(self, inputs): + self.inputs = inputs + if self.cfg.RCNN.ROI_SAMPLE_JIT: + if self.training: + proposal_target = get_proposal_target_func(self.cfg) + + tmp_list = [ + self.inputs['seg_mask'], + self.inputs['rpn_features'], + self.inputs['gt_boxes3d'], + self.inputs['rpn_xyz'], + self.inputs['pts_depth'], + self.inputs['roi_boxes3d'], + self.inputs['rpn_intensity'], + ] + out_name = ['reg_valid_mask' ,'sampled_pts' ,'roi_boxes3d', 'gt_of_rois', 'pts_feature' ,'cls_label','gt_iou'] + reg_valid_mask = self.create_tmp_var(name="reg_valid_mask",dtype='float32',shape=[-1,]) + sampled_pts = self.create_tmp_var(name="sampled_pts",dtype='float32',shape=[-1, self.cfg.RCNN.NUM_POINTS, 3]) + new_roi_boxes3d = self.create_tmp_var(name="new_roi_boxes3d",dtype='float32',shape=[-1, 7]) + gt_of_rois = self.create_tmp_var(name="gt_of_rois", dtype='float32', shape=[-1,7]) + pts_feature = self.create_tmp_var(name="pts_feature", dtype='float32',shape=[-1,512,130]) + cls_label = self.create_tmp_var(name="cls_label",dtype='int64',shape=[-1]) + gt_iou = self.create_tmp_var(name="gt_iou",dtype='float32',shape=[-1]) + + out_list = [reg_valid_mask, sampled_pts, new_roi_boxes3d, gt_of_rois, pts_feature, cls_label, gt_iou] + out = fluid.layers.py_func(func=proposal_target,x=tmp_list,out=out_list) + + self.target_dict = {} + for i,item in enumerate(out): + self.target_dict[out_name[i]] = item + + pts = fluid.layers.concat(input=[self.target_dict['sampled_pts'],self.target_dict['pts_feature']], axis=2) + self.debug = pts + self.target_dict['pts_input'] = pts + else: + rpn_xyz, rpn_features = inputs['rpn_xyz'], inputs['rpn_features'] + batch_rois = inputs['roi_boxes3d'] + rpn_intensity = inputs['rpn_intensity'] + rpn_intensity = fluid.layers.unsqueeze(rpn_intensity,axes=[2]) + seg_mask = fluid.layers.unsqueeze(inputs['seg_mask'],axes=[2]) + if self.cfg.RCNN.USE_INTENSITY: + pts_extra_input_list = [rpn_intensity, seg_mask] + else: + pts_extra_input_list = [seg_mask] + + if self.cfg.RCNN.USE_DEPTH: + pts_depth = inputs['pts_depth'] / 70.0 -0.5 + pts_depth = fluid.layers.unsqueeze(pts_depth,axes=[2]) + pts_extra_input_list.append(pts_depth) + pts_extra_input = fluid.layers.concat(pts_extra_input_list, axis=2) + pts_feature = fluid.layers.concat([pts_extra_input, rpn_features],axis=2) + + pooled_features, pooled_empty_flag = fluid.layers.roi_pool_3d(rpn_xyz,pts_feature,batch_rois, + self.cfg.RCNN.POOL_EXTRA_WIDTH, + sampled_pt_num=self.cfg.RCNN.NUM_POINTS) + # canonical transformation + batch_size = batch_rois.shape[0] + roi_center = batch_rois[:, :, 0:3] + tmp = pooled_features[:, :, :, 0:3] - fluid.layers.unsqueeze(roi_center,axes=[2]) + pooled_features = fluid.layers.concat(input=[tmp,pooled_features[:,:,:,3:]],axis=3) + concat_list = [] + for i in range(batch_size): + tmp = rotate_pc_along_y(pooled_features[i, :, :, 0:3], + batch_rois[i, :, 6]) + concat = fluid.layers.concat([tmp,pooled_features[i,:,:,3:]],axis=-1) + concat = fluid.layers.unsqueeze(concat,axes=[0]) + concat_list.append(concat) + pooled_features = fluid.layers.concat(concat_list,axis=0) + pts = fluid.layers.reshape(pooled_features,shape=[-1,pooled_features.shape[2],pooled_features.shape[3]]) + + else: + pts = inputs['pts_input'] + self.target_dict = {} + self.target_dict['pts_input'] = inputs['pts_input'] + self.target_dict['roi_boxes3d'] = inputs['roi_boxes3d'] + + if self.training: + self.target_dict['cls_label'] = inputs['cls_label'] + self.target_dict['reg_valid_mask'] = inputs['reg_valid_mask'] + self.target_dict['gt_of_rois'] = inputs['gt_boxes3d_ct'] + + xyz = pts[:,:,0:3] + feature = fluid.layers.transpose(pts[:,:,3:], [0,2,1]) if pts.shape[-1]>3 else None + if self.cfg.RCNN.USE_RPN_FEATURES: + self.rcnn_input_channel = 3 + int(self.cfg.RCNN.USE_INTENSITY) + \ + int(self.cfg.RCNN.USE_MASK) + int(self.cfg.RCNN.USE_DEPTH) + c_out = self.cfg.RCNN.XYZ_UP_LAYER[-1] + + xyz_input = pts[:,:,:self.rcnn_input_channel] + xyz_input = fluid.layers.transpose(xyz_input, [0,2,1]) + xyz_input = fluid.layers.unsqueeze(xyz_input, axes=[3]) + + rpn_feature = pts[:,:,self.rcnn_input_channel:] + rpn_feature = fluid.layers.transpose(rpn_feature, [0,2,1]) + rpn_feature = fluid.layers.unsqueeze(rpn_feature,axes=[3]) + + xyz_feature = MLP( + xyz_input, + out_channels_list=self.cfg.RCNN.XYZ_UP_LAYER, + bn=self.cfg.RCNN.USE_BN, + name="xyz_up_layer") + + merged_feature = fluid.layers.concat([xyz_feature, rpn_feature],axis=1) + merged_feature = MLP( + merged_feature, + out_channels_list=[c_out], + bn=self.cfg.RCNN.USE_BN, + name="xyz_down_layer") + + xyzs = [xyz] + features = [fluid.layers.squeeze(merged_feature,axes=[3])] + else: + xyzs = [xyz] + features = [feature] + + # forward + xyzi, featurei = xyzs[-1], features[-1] + for k in range(len(self.cfg.RCNN.SA_CONFIG.NPOINTS)): + mlps = self.cfg.RCNN.SA_CONFIG.MLPS[k] + npoint = self.cfg.RCNN.SA_CONFIG.NPOINTS[k] if self.cfg.RCNN.SA_CONFIG.NPOINTS[k] != -1 else None + + xyzi, featurei = pointnet_sa_module( + xyz=xyzi, + feature = featurei, + bn = self.cfg.RCNN.USE_BN, + use_xyz = self.use_xyz, + name = "sa_{}".format(k), + npoint = npoint, + mlps = [mlps], + radiuss = [self.cfg.RCNN.SA_CONFIG.RADIUS[k]], + nsamples = [self.cfg.RCNN.SA_CONFIG.NSAMPLE[k]] + ) + xyzs.append(xyzi) + features.append(featurei) + + head_in = features[-1] + head_in = fluid.layers.unsqueeze(head_in, axes=[2]) + + cls_out = head_in + reg_out = cls_out + + for i in range(0, self.cfg.RCNN.CLS_FC.__len__()): + cls_out = conv_bn(cls_out, self.cfg.RCNN.CLS_FC[i], bn=self.cfg.RCNN.USE_BN, name='rcnn_cls_{}'.format(i)) + if i == 0 and self.cfg.RCNN.DP_RATIO >= 0: + cls_out = fluid.layers.dropout(cls_out, self.cfg.RCNN.DP_RATIO, dropout_implementation="upscale_in_train") + cls_channel = 1 if self.num_classes == 2 else self.num_classes + cls_out = conv_bn(cls_out, cls_channel, act=None, name="cls_out", bn=self.cfg.RCNN.USE_BN) + self.cls_out = fluid.layers.squeeze(cls_out,axes=[1,3]) + + per_loc_bin_num = int(self.cfg.RCNN.LOC_SCOPE / self.cfg.RCNN.LOC_BIN_SIZE) * 2 + loc_y_bin_num = int(self.cfg.RCNN.LOC_Y_SCOPE / self.cfg.RCNN.LOC_Y_BIN_SIZE) * 2 + reg_channel = per_loc_bin_num * 4 + self.cfg.RCNN.NUM_HEAD_BIN * 2 + 3 + reg_channel += (1 if not self.cfg.RCNN.LOC_Y_BY_BIN else loc_y_bin_num * 2) + for i in range(0, self.cfg.RCNN.REG_FC.__len__()): + reg_out = conv_bn(reg_out, self.cfg.RCNN.REG_FC[i], bn=self.cfg.RCNN.USE_BN, name='rcnn_reg_{}'.format(i)) + if i == 0 and self.cfg.RCNN.DP_RATIO >= 0: + reg_out = fluid.layers.dropout(reg_out, self.cfg.RCNN.DP_RATIO, dropout_implementation="upscale_in_train") + + reg_out = conv_bn(reg_out, reg_channel, act=None, name="reg_out", bn=self.cfg.RCNN.USE_BN) + self.reg_out = fluid.layers.squeeze(reg_out, axes=[2,3]) + + + self.outputs = { + 'rcnn_cls':self.cls_out, + 'rcnn_reg':self.reg_out, + } + if self.training: + self.outputs.update(self.target_dict) + elif not self.training: + self.outputs['sample_id'] = inputs['sample_id'] + self.outputs['pts_input'] = inputs['pts_input'] + self.outputs['roi_boxes3d'] = inputs['roi_boxes3d'] + self.outputs['roi_scores'] = inputs['roi_scores'] + self.outputs['gt_iou'] = inputs['gt_iou'] + self.outputs['gt_boxes3d'] = inputs['gt_boxes3d'] + + if self.cls_out.shape[1] == 1: + raw_scores = fluid.layers.reshape(self.cls_out, shape=[-1]) + norm_scores = fluid.layers.sigmoid(raw_scores) + else: + norm_scores = fluid.layers.softmax(self.cls_out, axis=1) + self.outputs['norm_scores'] = norm_scores + + def get_outputs(self): + return self.outputs + + def get_loss(self): + assert self.inputs is not None, \ + "please call build() first" + rcnn_cls_label = self.outputs['cls_label'] + reg_valid_mask = self.outputs['reg_valid_mask'] + roi_boxes3d = self.outputs['roi_boxes3d'] + roi_size = roi_boxes3d[:, 3:6] + gt_boxes3d_ct = self.outputs['gt_of_rois'] + pts_input = self.outputs['pts_input'] + + rcnn_cls = self.cls_out + rcnn_reg = self.reg_out + + # RCNN classification loss + assert self.cfg.RCNN.LOSS_CLS in ["SigmoidFocalLoss", "BinaryCrossEntropy"], \ + "unsupported RCNN cls loss type {}".format(self.cfg.RCNN.LOSS_CLS) + + if self.cfg.RCNN.LOSS_CLS == "SigmoidFocalLoss": + cls_flat = fluid.layers.reshape(self.cls_out, shape=[-1]) + cls_label_flat = fluid.layers.reshape(rcnn_cls_label, shape=[-1]) + cls_label_flat = fluid.layers.cast(cls_label_flat, dtype=cls_flat.dtype) + cls_target = fluid.layers.cast(cls_label_flat>0, dtype=cls_flat.dtype) + cls_label_flat.stop_gradient = True + pos = fluid.layers.cast(cls_label_flat > 0, dtype=cls_flat.dtype) + pos.stop_gradient = True + pos_normalizer = fluid.layers.reduce_sum(pos) + cls_weights = fluid.layers.cast(cls_label_flat >= 0, dtype=cls_flat.dtype) + cls_weights = cls_weights / fluid.layers.clip(pos_normalizer, min=1.0, max=1e10) + cls_weights.stop_gradient = True + rcnn_loss_cls = sigmoid_focal_loss(cls_flat, cls_target, cls_weights) + rcnn_loss_cls = fluid.layers.reduce_sum(rcnn_loss_cls) + else: # BinaryCrossEntropy + cls_label = fluid.layers.reshape(rcnn_cls_label, shape=self.cls_out.shape) + cls_valid_mask = fluid.layers.cast(cls_label >= 0, dtype=self.cls_out.dtype) + cls_label = fluid.layers.cast(cls_label, dtype=self.cls_out.dtype) + cls_label.stop_gradient = True + rcnn_loss_cls = fluid.layers.sigmoid_cross_entropy_with_logits(self.cls_out, cls_label) + cls_mask_normalzer = fluid.layers.reduce_sum(cls_valid_mask) + rcnn_loss_cls = fluid.layers.reduce_sum(rcnn_loss_cls * cls_valid_mask) \ + / fluid.layers.clip(cls_mask_normalzer, min=1.0, max=1e10) + + # RCNN regression loss + reg_out = self.reg_out + fg_mask = fluid.layers.cast(reg_valid_mask > 0, dtype=reg_out.dtype) + fg_mask.stop_gradient = True + gt_boxes3d_ct = fluid.layers.reshape(gt_boxes3d_ct, [-1,7]) + all_anchor_size = roi_size + anchor_size = all_anchor_size[fg_mask] if self.cfg.RCNN.SIZE_RES_ON_ROI else self.cfg.CLS_MEAN_SIZE[0] + + loc_loss, angle_loss, size_loss, loss_dict = get_reg_loss( + reg_out * fg_mask, + gt_boxes3d_ct, + fg_mask, + point_num=float(self.batch_size*64), + loc_scope=self.cfg.RCNN.LOC_SCOPE, + loc_bin_size=self.cfg.RCNN.LOC_BIN_SIZE, + num_head_bin=self.cfg.RCNN.NUM_HEAD_BIN, + anchor_size=anchor_size, + get_xz_fine=True, + get_y_by_bin=self.cfg.RCNN.LOC_Y_BY_BIN, + loc_y_scope=self.cfg.RCNN.LOC_Y_SCOPE, + loc_y_bin_size=self.cfg.RCNN.LOC_Y_BIN_SIZE, + get_ry_fine=True + ) + rcnn_loss_reg = loc_loss + angle_loss + size_loss * 3 + rcnn_loss = rcnn_loss_cls + rcnn_loss_reg + return rcnn_loss, rcnn_loss_cls, rcnn_loss_reg + diff --git a/PaddleCV/Paddle3D/PointRCNN/models/rpn.py b/PaddleCV/Paddle3D/PointRCNN/models/rpn.py new file mode 100644 index 00000000..30f0e345 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/models/rpn.py @@ -0,0 +1,167 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +#Licensed under the Apache License, Version 2.0 (the "License"); +#you may not use this file except in compliance with the License. +#You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +#Unless required by applicable law or agreed to in writing, software +#distributed under the License is distributed on an "AS IS" BASIS, +#WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#See the License for the specific language governing permissions and +#limitations under the License. + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import numpy as np + +import paddle.fluid as fluid +from paddle.fluid.param_attr import ParamAttr +from paddle.fluid.initializer import Normal, Constant + +from utils.proposal_utils import get_proposal_func +from models.pointnet2_msg import PointNet2MSG +from models.pointnet2_modules import conv_bn +from models.loss_utils import sigmoid_focal_loss, get_reg_loss + +__all__ = ["RPN"] + + +class RPN(object): + def __init__(self, cfg, batch_size, use_xyz=True, mode='TRAIN', prog=None): + self.cfg = cfg + self.batch_size = batch_size + self.use_xyz = use_xyz + self.mode = mode + self.is_train = mode == 'TRAIN' + self.inputs = None + self.prog = fluid.default_main_program() if prog is None else prog + + def build(self, inputs): + assert self.cfg.RPN.BACKBONE == 'pointnet2_msg', \ + "RPN backbone only support pointnet2_msg" + self.inputs = inputs + self.outputs = {} + + xyz = inputs["pts_input"] + assert not self.cfg.RPN.USE_INTENSITY, \ + "RPN.USE_INTENSITY not support now" + feature = None + msg = PointNet2MSG(self.cfg, xyz, feature, self.use_xyz) + backbone_xyz, backbone_feature = msg.build() + self.outputs['backbone_xyz'] = backbone_xyz + self.outputs['backbone_feature'] = backbone_feature + + backbone_feature = fluid.layers.transpose(backbone_feature, perm=[0, 2, 1]) + cls_out = fluid.layers.unsqueeze(backbone_feature, axes=[-1]) + reg_out = cls_out + + # classification branch + for i in range(self.cfg.RPN.CLS_FC.__len__()): + cls_out = conv_bn(cls_out, self.cfg.RPN.CLS_FC[i], bn=self.cfg.RPN.USE_BN, name='rpn_cls_{}'.format(i)) + if i == 0 and self.cfg.RPN.DP_RATIO > 0: + cls_out = fluid.layers.dropout(cls_out, self.cfg.RPN.DP_RATIO, dropout_implementation="upscale_in_train") + cls_out = fluid.layers.conv2d(cls_out, + num_filters=1, + filter_size=1, + stride=1, + padding=0, + dilation=1, + param_attr=ParamAttr(name='rpn_cls_out_conv_weight'), + bias_attr=ParamAttr(name='rpn_cls_out_conv_bias', + initializer=Constant(-np.log(99)))) + cls_out = fluid.layers.squeeze(cls_out, axes=[1, 3]) + self.outputs['rpn_cls'] = cls_out + + # regression branch + per_loc_bin_num = int(self.cfg.RPN.LOC_SCOPE / self.cfg.RPN.LOC_BIN_SIZE) * 2 + if self.cfg.RPN.LOC_XZ_FINE: + reg_channel = per_loc_bin_num * 4 + self.cfg.RPN.NUM_HEAD_BIN * 2 + 3 + else: + reg_channel = per_loc_bin_num * 2 + self.cfg.RPN.NUM_HEAD_BIN * 2 + 3 + reg_channel += 1 # reg y + + for i in range(self.cfg.RPN.REG_FC.__len__()): + reg_out = conv_bn(reg_out, self.cfg.RPN.REG_FC[i], bn=self.cfg.RPN.USE_BN, name='rpn_reg_{}'.format(i)) + if i == 0 and self.cfg.RPN.DP_RATIO > 0: + reg_out = fluid.layers.dropout(reg_out, self.cfg.RPN.DP_RATIO, dropout_implementation="upscale_in_train") + reg_out = fluid.layers.conv2d(reg_out, + num_filters=reg_channel, + filter_size=1, + stride=1, + padding=0, + dilation=1, + param_attr=ParamAttr(name='rpn_reg_out_conv_weight', + initializer=Normal(0., 0.001),), + bias_attr=ParamAttr(name='rpn_reg_out_conv_bias')) + reg_out = fluid.layers.squeeze(reg_out, axes=[3]) + reg_out = fluid.layers.transpose(reg_out, [0, 2, 1]) + self.outputs['rpn_reg'] = reg_out + + if self.mode != 'TRAIN' or self.cfg.RCNN.ENABLED: + rpn_scores_row = cls_out + rpn_scores_norm = fluid.layers.sigmoid(rpn_scores_row) + seg_mask = fluid.layers.cast(rpn_scores_norm > self.cfg.RPN.SCORE_THRESH, dtype='float32') + pts_depth = fluid.layers.sqrt(fluid.layers.reduce_sum(backbone_xyz * backbone_xyz, dim=2)) + proposal_func = get_proposal_func(self.cfg, self.mode) + proposal_input = fluid.layers.concat([fluid.layers.unsqueeze(rpn_scores_row, axes=[-1]), + backbone_xyz, reg_out], axis=-1) + proposal = self.prog.current_block().create_var(name='proposal', + shape=[-1, proposal_input.shape[1], 8], + dtype='float32') + fluid.layers.py_func(proposal_func, proposal_input, proposal) + rois, roi_scores_row = proposal[:, :, :7], proposal[:, :, -1] + self.outputs['rois'] = rois + self.outputs['roi_scores_row'] = roi_scores_row + self.outputs['seg_mask'] = seg_mask + self.outputs['pts_depth'] = pts_depth + + def get_outputs(self): + return self.outputs + + def get_loss(self): + assert self.inputs is not None, \ + "please call build() first" + rpn_cls_label = self.inputs['rpn_cls_label'] + rpn_reg_label = self.inputs['rpn_reg_label'] + rpn_cls = self.outputs['rpn_cls'] + rpn_reg = self.outputs['rpn_reg'] + + # RPN classification loss + assert self.cfg.RPN.LOSS_CLS == "SigmoidFocalLoss", \ + "unsupported RPN cls loss type {}".format(self.cfg.RPN.LOSS_CLS) + cls_flat = fluid.layers.reshape(rpn_cls, shape=[-1]) + cls_label_flat = fluid.layers.reshape(rpn_cls_label, shape=[-1]) + cls_label_pos = fluid.layers.cast(cls_label_flat > 0, dtype=cls_flat.dtype) + pos_normalizer = fluid.layers.reduce_sum(cls_label_pos) + cls_weights = fluid.layers.cast(cls_label_flat >= 0, dtype=cls_flat.dtype) + cls_weights = cls_weights / fluid.layers.clip(pos_normalizer, min=1.0, max=1e10) + cls_weights.stop_gradient = True + cls_label_flat = fluid.layers.cast(cls_label_flat, dtype=cls_flat.dtype) + cls_label_flat.stop_gradient = True + rpn_loss_cls = sigmoid_focal_loss(cls_flat, cls_label_pos, cls_weights) + rpn_loss_cls = fluid.layers.reduce_sum(rpn_loss_cls) + + # RPN regression loss + rpn_reg = fluid.layers.reshape(rpn_reg, [-1, rpn_reg.shape[-1]]) + reg_label = fluid.layers.reshape(rpn_reg_label, [-1, rpn_reg_label.shape[-1]]) + fg_mask = fluid.layers.cast(cls_label_flat > 0, dtype=rpn_reg.dtype) + fg_mask.stop_gradient = True + loc_loss, angle_loss, size_loss, loss_dict = get_reg_loss( + rpn_reg * fg_mask, reg_label, fg_mask, + float(self.batch_size * self.cfg.RPN.NUM_POINTS), + loc_scope=self.cfg.RPN.LOC_SCOPE, + loc_bin_size=self.cfg.RPN.LOC_BIN_SIZE, + num_head_bin=self.cfg.RPN.NUM_HEAD_BIN, + anchor_size=self.cfg.CLS_MEAN_SIZE[0], + get_xz_fine=self.cfg.RPN.LOC_XZ_FINE, + get_y_by_bin=False, + get_ry_fine=False) + rpn_loss_reg = loc_loss + angle_loss + size_loss * 3 + + self.rpn_loss = rpn_loss_cls * self.cfg.RPN.LOSS_WEIGHT[0] + rpn_loss_reg * self.cfg.RPN.LOSS_WEIGHT[1] + return self.rpn_loss, rpn_loss_cls, rpn_loss_reg + diff --git a/PaddleCV/Paddle3D/PointRCNN/requirement.txt b/PaddleCV/Paddle3D/PointRCNN/requirement.txt new file mode 100644 index 00000000..6ff347ab --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/requirement.txt @@ -0,0 +1,6 @@ +Cython +opencv-python +shapely +scikit-image +Numba +fire diff --git a/PaddleCV/Paddle3D/PointRCNN/tools/generate_aug_scene.py b/PaddleCV/Paddle3D/PointRCNN/tools/generate_aug_scene.py new file mode 100644 index 00000000..59cfa4ab --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/tools/generate_aug_scene.py @@ -0,0 +1,330 @@ +""" +Generate GT database +This code is based on https://github.com/sshaoshuai/PointRCNN/blob/master/tools/generate_aug_scene.py +""" + +import os +import numpy as np +import pickle + +import pts_utils +import utils.cyops.kitti_utils as kitti_utils +from utils.box_utils import boxes_iou3d +from utils import calibration as calib +from data.kitti_dataset import KittiDataset +import argparse + +np.random.seed(1024) + +parser = argparse.ArgumentParser() +parser.add_argument('--mode', type=str, default='generator') +parser.add_argument('--class_name', type=str, default='Car') +parser.add_argument('--data_dir', type=str, default='./data') +parser.add_argument('--save_dir', type=str, default='./data/KITTI/aug_scene/training') +parser.add_argument('--split', type=str, default='train') +parser.add_argument('--gt_database_dir', type=str, default='./data/gt_database/train_gt_database_3level_Car.pkl') +parser.add_argument('--include_similar', action='store_true', default=False) +parser.add_argument('--aug_times', type=int, default=4) +args = parser.parse_args() + +PC_REDUCE_BY_RANGE = True +if args.class_name == 'Car': + PC_AREA_SCOPE = np.array([[-40, 40], [-1, 3], [0, 70.4]]) # x, y, z scope in rect camera coords +else: + PC_AREA_SCOPE = np.array([[-30, 30], [-1, 3], [0, 50]]) + + +def log_print(info, fp=None): + print(info) + if fp is not None: + # print(info, file=fp) + fp.write(info+"\n") + + +def save_kitti_format(calib, bbox3d, obj_list, img_shape, save_fp): + corners3d = kitti_utils.boxes3d_to_corners3d(bbox3d) + img_boxes, _ = calib.corners3d_to_img_boxes(corners3d) + + img_boxes[:, 0] = np.clip(img_boxes[:, 0], 0, img_shape[1] - 1) + img_boxes[:, 1] = np.clip(img_boxes[:, 1], 0, img_shape[0] - 1) + img_boxes[:, 2] = np.clip(img_boxes[:, 2], 0, img_shape[1] - 1) + img_boxes[:, 3] = np.clip(img_boxes[:, 3], 0, img_shape[0] - 1) + + # Discard boxes that are larger than 80% of the image width OR height + img_boxes_w = img_boxes[:, 2] - img_boxes[:, 0] + img_boxes_h = img_boxes[:, 3] - img_boxes[:, 1] + box_valid_mask = np.logical_and(img_boxes_w < img_shape[1] * 0.8, img_boxes_h < img_shape[0] * 0.8) + + for k in range(bbox3d.shape[0]): + if box_valid_mask[k] == 0: + continue + x, z, ry = bbox3d[k, 0], bbox3d[k, 2], bbox3d[k, 6] + beta = np.arctan2(z, x) + alpha = -np.sign(beta) * np.pi / 2 + beta + ry + + save_fp.write('%s %.2f %d %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f\n' % + (args.class_name, obj_list[k].trucation, int(obj_list[k].occlusion), alpha, img_boxes[k, 0], img_boxes[k, 1], + img_boxes[k, 2], img_boxes[k, 3], + bbox3d[k, 3], bbox3d[k, 4], bbox3d[k, 5], bbox3d[k, 0], bbox3d[k, 1], bbox3d[k, 2], + bbox3d[k, 6])) + + +class AugSceneGenerator(KittiDataset): + def __init__(self, root_dir, gt_database=None, split='train', classes=args.class_name): + super(AugSceneGenerator, self).__init__(root_dir, split=split) + self.gt_database = None + if classes == 'Car': + self.classes = ('Background', 'Car') + elif classes == 'People': + self.classes = ('Background', 'Pedestrian', 'Cyclist') + elif classes == 'Pedestrian': + self.classes = ('Background', 'Pedestrian') + elif classes == 'Cyclist': + self.classes = ('Background', 'Cyclist') + else: + assert False, "Invalid classes: %s" % classes + + self.gt_database = gt_database + + def __len__(self): + raise NotImplementedError + + def __getitem__(self, item): + raise NotImplementedError + + def filtrate_dc_objects(self, obj_list): + valid_obj_list = [] + for obj in obj_list: + if obj.cls_type in ['DontCare']: + continue + valid_obj_list.append(obj) + + return valid_obj_list + + def filtrate_objects(self, obj_list): + valid_obj_list = [] + type_whitelist = self.classes + if args.include_similar: + type_whitelist = list(self.classes) + if 'Car' in self.classes: + type_whitelist.append('Van') + if 'Pedestrian' in self.classes or 'Cyclist' in self.classes: + type_whitelist.append('Person_sitting') + + for obj in obj_list: + if obj.cls_type in type_whitelist: + valid_obj_list.append(obj) + return valid_obj_list + + @staticmethod + def get_valid_flag(pts_rect, pts_img, pts_rect_depth, img_shape): + """ + Valid point should be in the image (and in the PC_AREA_SCOPE) + :param pts_rect: + :param pts_img: + :param pts_rect_depth: + :param img_shape: + :return: + """ + val_flag_1 = np.logical_and(pts_img[:, 0] >= 0, pts_img[:, 0] < img_shape[1]) + val_flag_2 = np.logical_and(pts_img[:, 1] >= 0, pts_img[:, 1] < img_shape[0]) + val_flag_merge = np.logical_and(val_flag_1, val_flag_2) + pts_valid_flag = np.logical_and(val_flag_merge, pts_rect_depth >= 0) + + if PC_REDUCE_BY_RANGE: + x_range, y_range, z_range = PC_AREA_SCOPE + pts_x, pts_y, pts_z = pts_rect[:, 0], pts_rect[:, 1], pts_rect[:, 2] + range_flag = (pts_x >= x_range[0]) & (pts_x <= x_range[1]) \ + & (pts_y >= y_range[0]) & (pts_y <= y_range[1]) \ + & (pts_z >= z_range[0]) & (pts_z <= z_range[1]) + pts_valid_flag = pts_valid_flag & range_flag + return pts_valid_flag + + @staticmethod + def check_pc_range(xyz): + """ + :param xyz: [x, y, z] + :return: + """ + x_range, y_range, z_range = PC_AREA_SCOPE + if (x_range[0] <= xyz[0] <= x_range[1]) and (y_range[0] <= xyz[1] <= y_range[1]) and \ + (z_range[0] <= xyz[2] <= z_range[1]): + return True + return False + + def aug_one_scene(self, sample_id, pts_rect, pts_intensity, all_gt_boxes3d): + """ + :param pts_rect: (N, 3) + :param gt_boxes3d: (M1, 7) + :param all_gt_boxex3d: (M2, 7) + :return: + """ + assert self.gt_database is not None + extra_gt_num = np.random.randint(10, 15) + try_times = 50 + cnt = 0 + cur_gt_boxes3d = all_gt_boxes3d.copy() + cur_gt_boxes3d[:, 4] += 0.5 + cur_gt_boxes3d[:, 5] += 0.5 # enlarge new added box to avoid too nearby boxes + + extra_gt_obj_list = [] + extra_gt_boxes3d_list = [] + new_pts_list, new_pts_intensity_list = [], [] + src_pts_flag = np.ones(pts_rect.shape[0], dtype=np.int32) + + road_plane = self.get_road_plane(sample_id) + a, b, c, d = road_plane + + while try_times > 0: + try_times -= 1 + + rand_idx = np.random.randint(0, self.gt_database.__len__() - 1) + + new_gt_dict = self.gt_database[rand_idx] + new_gt_box3d = new_gt_dict['gt_box3d'].copy() + new_gt_points = new_gt_dict['points'].copy() + new_gt_intensity = new_gt_dict['intensity'].copy() + new_gt_obj = new_gt_dict['obj'] + center = new_gt_box3d[0:3] + if PC_REDUCE_BY_RANGE and (self.check_pc_range(center) is False): + continue + if cnt > extra_gt_num: + break + if new_gt_points.__len__() < 5: # too few points + continue + + # put it on the road plane + cur_height = (-d - a * center[0] - c * center[2]) / b + move_height = new_gt_box3d[1] - cur_height + new_gt_box3d[1] -= move_height + new_gt_points[:, 1] -= move_height + + cnt += 1 + + iou3d = boxes_iou3d(new_gt_box3d.reshape(1, 7), cur_gt_boxes3d) + + valid_flag = iou3d.max() < 1e-8 + if not valid_flag: + continue + + enlarged_box3d = new_gt_box3d.copy() + enlarged_box3d[3] += 2 # remove the points above and below the object + boxes_pts_mask_list = pts_utils.pts_in_boxes3d(pts_rect, enlarged_box3d.reshape(1, 7)) + pt_mask_flag = (boxes_pts_mask_list[0] == 1) + src_pts_flag[pt_mask_flag] = 0 # remove the original points which are inside the new box + + new_pts_list.append(new_gt_points) + new_pts_intensity_list.append(new_gt_intensity) + enlarged_box3d = new_gt_box3d.copy() + enlarged_box3d[4] += 0.5 + enlarged_box3d[5] += 0.5 # enlarge new added box to avoid too nearby boxes + cur_gt_boxes3d = np.concatenate((cur_gt_boxes3d, enlarged_box3d.reshape(1, 7)), axis=0) + extra_gt_boxes3d_list.append(new_gt_box3d.reshape(1, 7)) + extra_gt_obj_list.append(new_gt_obj) + + if new_pts_list.__len__() == 0: + return False, pts_rect, pts_intensity, None, None + + extra_gt_boxes3d = np.concatenate(extra_gt_boxes3d_list, axis=0) + # remove original points and add new points + pts_rect = pts_rect[src_pts_flag == 1] + pts_intensity = pts_intensity[src_pts_flag == 1] + new_pts_rect = np.concatenate(new_pts_list, axis=0) + new_pts_intensity = np.concatenate(new_pts_intensity_list, axis=0) + pts_rect = np.concatenate((pts_rect, new_pts_rect), axis=0) + pts_intensity = np.concatenate((pts_intensity, new_pts_intensity), axis=0) + + return True, pts_rect, pts_intensity, extra_gt_boxes3d, extra_gt_obj_list + + def aug_one_epoch_scene(self, base_id, data_save_dir, label_save_dir, split_list, log_fp=None): + for idx, sample_id in enumerate(self.image_idx_list): + sample_id = int(sample_id) + print('process gt sample (%s, id=%06d)' % (args.split, sample_id)) + + pts_lidar = self.get_lidar(sample_id) + calib = self.get_calib(sample_id) + pts_rect = calib.lidar_to_rect(pts_lidar[:, 0:3]) + pts_img, pts_rect_depth = calib.rect_to_img(pts_rect) + img_shape = self.get_image_shape(sample_id) + + pts_valid_flag = self.get_valid_flag(pts_rect, pts_img, pts_rect_depth, img_shape) + pts_rect = pts_rect[pts_valid_flag][:, 0:3] + pts_intensity = pts_lidar[pts_valid_flag][:, 3] + + # all labels for checking overlapping + all_obj_list = self.filtrate_dc_objects(self.get_label(sample_id)) + all_gt_boxes3d = np.zeros((all_obj_list.__len__(), 7), dtype=np.float32) + for k, obj in enumerate(all_obj_list): + all_gt_boxes3d[k, 0:3], all_gt_boxes3d[k, 3], all_gt_boxes3d[k, 4], all_gt_boxes3d[k, 5], \ + all_gt_boxes3d[k, 6] = obj.pos, obj.h, obj.w, obj.l, obj.ry + + # gt_boxes3d of current label + obj_list = self.filtrate_objects(self.get_label(sample_id)) + if args.class_name != 'Car' and obj_list.__len__() == 0: + continue + + # augment one scene + aug_flag, pts_rect, pts_intensity, extra_gt_boxes3d, extra_gt_obj_list = \ + self.aug_one_scene(sample_id, pts_rect, pts_intensity, all_gt_boxes3d) + + # save augment result to file + pts_info = np.concatenate((pts_rect, pts_intensity.reshape(-1, 1)), axis=1) + bin_file = os.path.join(data_save_dir, '%06d.bin' % (base_id + sample_id)) + pts_info.astype(np.float32).tofile(bin_file) + + # save filtered original gt_boxes3d + label_save_file = os.path.join(label_save_dir, '%06d.txt' % (base_id + sample_id)) + with open(label_save_file, 'w') as f: + for obj in obj_list: + f.write(obj.to_kitti_format() + '\n') + + if aug_flag: + # augment successfully + save_kitti_format(calib, extra_gt_boxes3d, extra_gt_obj_list, img_shape=img_shape, save_fp=f) + else: + extra_gt_boxes3d = np.zeros((0, 7), dtype=np.float32) + log_print('Save to file (new_obj: %s): %s' % (extra_gt_boxes3d.__len__(), label_save_file), fp=log_fp) + split_list.append('%06d' % (base_id + sample_id)) + + def generate_aug_scene(self, aug_times, log_fp=None): + data_save_dir = os.path.join(args.save_dir, 'rectified_data') + label_save_dir = os.path.join(args.save_dir, 'aug_label') + if not os.path.isdir(data_save_dir): + os.makedirs(data_save_dir) + if not os.path.isdir(label_save_dir): + os.makedirs(label_save_dir) + + split_file = os.path.join(args.save_dir, '%s_aug.txt' % args.split) + split_list = self.image_idx_list[:] + for epoch in range(aug_times): + base_id = (epoch + 1) * 10000 + self.aug_one_epoch_scene(base_id, data_save_dir, label_save_dir, split_list, log_fp=log_fp) + + with open(split_file, 'w') as f: + for idx, sample_id in enumerate(split_list): + f.write(str(sample_id) + '\n') + log_print('Save split file to %s' % split_file, fp=log_fp) + target_dir = os.path.join(args.data_dir, 'KITTI/ImageSets/') + os.system('cp %s %s' % (split_file, target_dir)) + log_print('Copy split file from %s to %s' % (split_file, target_dir), fp=log_fp) + + +if __name__ == '__main__': + if not os.path.isdir(args.save_dir): + os.makedirs(args.save_dir) + info_file = os.path.join(args.save_dir, 'log_info.txt') + + if args.mode == 'generator': + log_fp = open(info_file, 'w') + + gt_database = pickle.load(open(args.gt_database_dir, 'rb')) + log_print('Loading gt_database(%d) from %s' % (gt_database.__len__(), args.gt_database_dir), fp=log_fp) + + dataset = AugSceneGenerator(root_dir=args.data_dir, gt_database=gt_database, split=args.split) + dataset.generate_aug_scene(aug_times=args.aug_times, log_fp=log_fp) + + log_fp.close() + + else: + pass + diff --git a/PaddleCV/Paddle3D/PointRCNN/tools/generate_gt_database.py b/PaddleCV/Paddle3D/PointRCNN/tools/generate_gt_database.py new file mode 100644 index 00000000..43290db7 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/tools/generate_gt_database.py @@ -0,0 +1,104 @@ +""" +Generate GT database +This code is based on https://github.com/sshaoshuai/PointRCNN/blob/master/tools/generate_gt_database.py +""" + +import os +import numpy as np +import pickle + +from data.kitti_dataset import KittiDataset +import pts_utils +import argparse + +parser = argparse.ArgumentParser() +parser.add_argument('--data_dir', type=str, default='./data') +parser.add_argument('--save_dir', type=str, default='./data/gt_database') +parser.add_argument('--class_name', type=str, default='Car') +parser.add_argument('--split', type=str, default='train') +args = parser.parse_args() + + +class GTDatabaseGenerator(KittiDataset): + def __init__(self, root_dir, split='train', classes=args.class_name): + super(GTDatabaseGenerator, self).__init__(root_dir, split=split) + self.gt_database = None + if classes == 'Car': + self.classes = ('Background', 'Car') + elif classes == 'People': + self.classes = ('Background', 'Pedestrian', 'Cyclist') + elif classes == 'Pedestrian': + self.classes = ('Background', 'Pedestrian') + elif classes == 'Cyclist': + self.classes = ('Background', 'Cyclist') + else: + assert False, "Invalid classes: %s" % classes + + def __len__(self): + raise NotImplementedError + + def __getitem__(self, item): + raise NotImplementedError + + def filtrate_objects(self, obj_list): + valid_obj_list = [] + for obj in obj_list: + if obj.cls_type not in self.classes: + continue + if obj.level_str not in ['Easy', 'Moderate', 'Hard']: + continue + valid_obj_list.append(obj) + + return valid_obj_list + + def generate_gt_database(self): + gt_database = [] + for idx, sample_id in enumerate(self.image_idx_list): + sample_id = int(sample_id) + print('process gt sample (id=%06d)' % sample_id) + + pts_lidar = self.get_lidar(sample_id) + calib = self.get_calib(sample_id) + pts_rect = calib.lidar_to_rect(pts_lidar[:, 0:3]) + pts_intensity = pts_lidar[:, 3] + + obj_list = self.filtrate_objects(self.get_label(sample_id)) + + gt_boxes3d = np.zeros((obj_list.__len__(), 7), dtype=np.float32) + for k, obj in enumerate(obj_list): + gt_boxes3d[k, 0:3], gt_boxes3d[k, 3], gt_boxes3d[k, 4], gt_boxes3d[k, 5], gt_boxes3d[k, 6] \ + = obj.pos, obj.h, obj.w, obj.l, obj.ry + + if gt_boxes3d.__len__() == 0: + print('No gt object') + continue + + boxes_pts_mask_list = pts_utils.pts_in_boxes3d(pts_rect, gt_boxes3d) + + for k in range(boxes_pts_mask_list.shape[0]): + pt_mask_flag = (boxes_pts_mask_list[k] == 1) + cur_pts = pts_rect[pt_mask_flag].astype(np.float32) + cur_pts_intensity = pts_intensity[pt_mask_flag].astype(np.float32) + sample_dict = {'sample_id': sample_id, + 'cls_type': obj_list[k].cls_type, + 'gt_box3d': gt_boxes3d[k], + 'points': cur_pts, + 'intensity': cur_pts_intensity, + 'obj': obj_list[k]} + gt_database.append(sample_dict) + + save_file_name = os.path.join(args.save_dir, '%s_gt_database_3level_%s.pkl' % (args.split, self.classes[-1])) + with open(save_file_name, 'wb') as f: + pickle.dump(gt_database, f) + + self.gt_database = gt_database + print('Save refine training sample info file to %s' % save_file_name) + + +if __name__ == '__main__': + dataset = GTDatabaseGenerator(root_dir=args.data_dir, split=args.split) + if not os.path.isdir(args.save_dir): + os.makedirs(args.save_dir) + + dataset.generate_gt_database() + diff --git a/PaddleCV/Paddle3D/PointRCNN/tools/kitti_eval.py b/PaddleCV/Paddle3D/PointRCNN/tools/kitti_eval.py new file mode 100644 index 00000000..6d16ef48 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/tools/kitti_eval.py @@ -0,0 +1,71 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import sys +import argparse + + +def parse_args(): + parser = argparse.ArgumentParser( + "KITTI mAP evaluation script") + parser.add_argument( + '--result_dir', + type=str, + default='./result_dir', + help='detection result directory to evaluate') + parser.add_argument( + '--data_dir', + type=str, + default='./data', + help='KITTI dataset root directory') + parser.add_argument( + '--split', + type=str, + default='val', + help='evaluation split, default val') + parser.add_argument( + '--class_name', + type=str, + default='Car', + help='evaluation class name, default Car') + args = parser.parse_args() + return args + + +def kitti_eval(): + if float(sys.version[:3]) < 3.6: + print("KITTI mAP evaluation can only run with python3.6+") + sys.exit(1) + + args = parse_args() + + label_dir = os.path.join(args.data_dir, 'KITTI/object/training', 'label_2') + split_file = os.path.join(args.data_dir, 'KITTI/ImageSets', + '{}.txt'.format(args.split)) + final_output_dir = os.path.join(args.result_dir, 'final_result', 'data') + name_to_class = {'Car': 0, 'Pedestrian': 1, 'Cyclist': 2} + + from tools.kitti_object_eval_python.evaluate import evaluate as kitti_evaluate + ap_result_str, ap_dict = kitti_evaluate( + label_dir, final_output_dir, label_split_file=split_file, + current_class=name_to_class[args.class_name]) + + print("KITTI evaluate: ", ap_result_str, ap_dict) + + +if __name__ == "__main__": + kitti_eval() + + diff --git a/PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/LICENSE b/PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/LICENSE new file mode 100644 index 00000000..ab602974 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/README.md b/PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/README.md new file mode 100644 index 00000000..0e0e0c30 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/README.md @@ -0,0 +1,32 @@ +# kitti-object-eval-python +**NOTE**: This is borrowed from [traveller59/kitti-object-eval-python](https://github.com/traveller59/kitti-object-eval-python) + +Fast kitti object detection eval in python(finish eval in less than 10 second), support 2d/bev/3d/aos. , support coco-style AP. If you use command line interface, numba need some time to compile jit functions. +## Dependencies +Only support python 3.6+, need `numpy`, `skimage`, `numba`, `fire`. If you have Anaconda, just install `cudatoolkit` in anaconda. Otherwise, please reference to this [page](https://github.com/numba/numba#custom-python-environments) to set up llvm and cuda for numba. +* Install by conda: +``` +conda install -c numba cudatoolkit=x.x (8.0, 9.0, 9.1, depend on your environment) +``` +## Usage +* commandline interface: +``` +python evaluate.py evaluate --label_path=/path/to/your_gt_label_folder --result_path=/path/to/your_result_folder --label_split_file=/path/to/val.txt --current_class=0 --coco=False +``` +* python interface: +```Python +import kitti_common as kitti +from eval import get_official_eval_result, get_coco_eval_result +def _read_imageset_file(path): + with open(path, 'r') as f: + lines = f.readlines() + return [int(line) for line in lines] +det_path = "/path/to/your_result_folder" +dt_annos = kitti.get_label_annos(det_path) +gt_path = "/path/to/your_gt_label_folder" +gt_split_file = "/path/to/val.txt" # from https://xiaozhichen.github.io/files/mv3d/imagesets.tar.gz +val_image_ids = _read_imageset_file(gt_split_file) +gt_annos = kitti.get_label_annos(gt_path, val_image_ids) +print(get_official_eval_result(gt_annos, dt_annos, 0)) # 6s in my computer +print(get_coco_eval_result(gt_annos, dt_annos, 0)) # 18s in my computer +``` diff --git a/PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/eval.py b/PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/eval.py new file mode 100644 index 00000000..38101ca6 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/eval.py @@ -0,0 +1,740 @@ +import numpy as np +import numba +import io as sysio +from tools.kitti_object_eval_python.rotate_iou import rotate_iou_gpu_eval + + +@numba.jit +def get_thresholds(scores: np.ndarray, num_gt, num_sample_pts=41): + scores.sort() + scores = scores[::-1] + current_recall = 0 + thresholds = [] + for i, score in enumerate(scores): + l_recall = (i + 1) / num_gt + if i < (len(scores) - 1): + r_recall = (i + 2) / num_gt + else: + r_recall = l_recall + if (((r_recall - current_recall) < (current_recall - l_recall)) + and (i < (len(scores) - 1))): + continue + # recall = l_recall + thresholds.append(score) + current_recall += 1 / (num_sample_pts - 1.0) + return thresholds + + +def clean_data(gt_anno, dt_anno, current_class, difficulty): + CLASS_NAMES = ['car', 'pedestrian', 'cyclist'] + MIN_HEIGHT = [40, 25, 25] + MAX_OCCLUSION = [0, 1, 2] + MAX_TRUNCATION = [0.15, 0.3, 0.5] + dc_bboxes, ignored_gt, ignored_dt = [], [], [] + current_cls_name = CLASS_NAMES[current_class].lower() + num_gt = len(gt_anno["name"]) + num_dt = len(dt_anno["name"]) + num_valid_gt = 0 + for i in range(num_gt): + bbox = gt_anno["bbox"][i] + gt_name = gt_anno["name"][i].lower() + height = bbox[3] - bbox[1] + valid_class = -1 + if (gt_name == current_cls_name): + valid_class = 1 + elif (current_cls_name == "Pedestrian".lower() + and "Person_sitting".lower() == gt_name): + valid_class = 0 + elif (current_cls_name == "Car".lower() and "Van".lower() == gt_name): + valid_class = 0 + else: + valid_class = -1 + ignore = False + if ((gt_anno["occluded"][i] > MAX_OCCLUSION[difficulty]) + or (gt_anno["truncated"][i] > MAX_TRUNCATION[difficulty]) + or (height <= MIN_HEIGHT[difficulty])): + # if gt_anno["difficulty"][i] > difficulty or gt_anno["difficulty"][i] == -1: + ignore = True + if valid_class == 1 and not ignore: + ignored_gt.append(0) + num_valid_gt += 1 + elif (valid_class == 0 or (ignore and (valid_class == 1))): + ignored_gt.append(1) + else: + ignored_gt.append(-1) + # for i in range(num_gt): + if gt_anno["name"][i] == "DontCare": + dc_bboxes.append(gt_anno["bbox"][i]) + for i in range(num_dt): + if (dt_anno["name"][i].lower() == current_cls_name): + valid_class = 1 + else: + valid_class = -1 + height = abs(dt_anno["bbox"][i, 3] - dt_anno["bbox"][i, 1]) + if height < MIN_HEIGHT[difficulty]: + ignored_dt.append(1) + elif valid_class == 1: + ignored_dt.append(0) + else: + ignored_dt.append(-1) + + return num_valid_gt, ignored_gt, ignored_dt, dc_bboxes + + +@numba.jit(nopython=True) +def image_box_overlap(boxes, query_boxes, criterion=-1): + N = boxes.shape[0] + K = query_boxes.shape[0] + overlaps = np.zeros((N, K), dtype=boxes.dtype) + for k in range(K): + qbox_area = ((query_boxes[k, 2] - query_boxes[k, 0]) * + (query_boxes[k, 3] - query_boxes[k, 1])) + for n in range(N): + iw = (min(boxes[n, 2], query_boxes[k, 2]) - + max(boxes[n, 0], query_boxes[k, 0])) + if iw > 0: + ih = (min(boxes[n, 3], query_boxes[k, 3]) - + max(boxes[n, 1], query_boxes[k, 1])) + if ih > 0: + if criterion == -1: + ua = ( + (boxes[n, 2] - boxes[n, 0]) * + (boxes[n, 3] - boxes[n, 1]) + qbox_area - iw * ih) + elif criterion == 0: + ua = ((boxes[n, 2] - boxes[n, 0]) * + (boxes[n, 3] - boxes[n, 1])) + elif criterion == 1: + ua = qbox_area + else: + ua = 1.0 + overlaps[n, k] = iw * ih / ua + return overlaps + + +def bev_box_overlap(boxes, qboxes, criterion=-1): + riou = rotate_iou_gpu_eval(boxes, qboxes, criterion) + return riou + + +@numba.jit(nopython=True, parallel=True) +def d3_box_overlap_kernel(boxes, qboxes, rinc, criterion=-1): + # ONLY support overlap in CAMERA, not lider. + N, K = boxes.shape[0], qboxes.shape[0] + for i in range(N): + for j in range(K): + if rinc[i, j] > 0: + # iw = (min(boxes[i, 1] + boxes[i, 4], qboxes[j, 1] + + # qboxes[j, 4]) - max(boxes[i, 1], qboxes[j, 1])) + iw = (min(boxes[i, 1], qboxes[j, 1]) - max( + boxes[i, 1] - boxes[i, 4], qboxes[j, 1] - qboxes[j, 4])) + + if iw > 0: + area1 = boxes[i, 3] * boxes[i, 4] * boxes[i, 5] + area2 = qboxes[j, 3] * qboxes[j, 4] * qboxes[j, 5] + inc = iw * rinc[i, j] + if criterion == -1: + ua = (area1 + area2 - inc) + elif criterion == 0: + ua = area1 + elif criterion == 1: + ua = area2 + else: + ua = inc + rinc[i, j] = inc / ua + else: + rinc[i, j] = 0.0 + + +def d3_box_overlap(boxes, qboxes, criterion=-1): + rinc = rotate_iou_gpu_eval(boxes[:, [0, 2, 3, 5, 6]], + qboxes[:, [0, 2, 3, 5, 6]], 2) + d3_box_overlap_kernel(boxes, qboxes, rinc, criterion) + return rinc + + +@numba.jit(nopython=True) +def compute_statistics_jit(overlaps, + gt_datas, + dt_datas, + ignored_gt, + ignored_det, + dc_bboxes, + metric, + min_overlap, + thresh=0, + compute_fp=False, + compute_aos=False): + + det_size = dt_datas.shape[0] + gt_size = gt_datas.shape[0] + dt_scores = dt_datas[:, -1] + dt_alphas = dt_datas[:, 4] + gt_alphas = gt_datas[:, 4] + dt_bboxes = dt_datas[:, :4] + gt_bboxes = gt_datas[:, :4] + + assigned_detection = [False] * det_size + ignored_threshold = [False] * det_size + if compute_fp: + for i in range(det_size): + if (dt_scores[i] < thresh): + ignored_threshold[i] = True + NO_DETECTION = -10000000 + tp, fp, fn, similarity = 0, 0, 0, 0 + # thresholds = [0.0] + # delta = [0.0] + thresholds = np.zeros((gt_size, )) + thresh_idx = 0 + delta = np.zeros((gt_size, )) + delta_idx = 0 + for i in range(gt_size): + if ignored_gt[i] == -1: + continue + det_idx = -1 + valid_detection = NO_DETECTION + max_overlap = 0 + assigned_ignored_det = False + + for j in range(det_size): + if (ignored_det[j] == -1): + continue + if (assigned_detection[j]): + continue + if (ignored_threshold[j]): + continue + overlap = overlaps[j, i] + dt_score = dt_scores[j] + if (not compute_fp and (overlap > min_overlap) + and dt_score > valid_detection): + det_idx = j + valid_detection = dt_score + elif (compute_fp and (overlap > min_overlap) + and (overlap > max_overlap or assigned_ignored_det) + and ignored_det[j] == 0): + max_overlap = overlap + det_idx = j + valid_detection = 1 + assigned_ignored_det = False + elif (compute_fp and (overlap > min_overlap) + and (valid_detection == NO_DETECTION) + and ignored_det[j] == 1): + det_idx = j + valid_detection = 1 + assigned_ignored_det = True + + if (valid_detection == NO_DETECTION) and ignored_gt[i] == 0: + fn += 1 + elif ((valid_detection != NO_DETECTION) + and (ignored_gt[i] == 1 or ignored_det[det_idx] == 1)): + assigned_detection[det_idx] = True + elif valid_detection != NO_DETECTION: + tp += 1 + # thresholds.append(dt_scores[det_idx]) + thresholds[thresh_idx] = dt_scores[det_idx] + thresh_idx += 1 + if compute_aos: + # delta.append(gt_alphas[i] - dt_alphas[det_idx]) + delta[delta_idx] = gt_alphas[i] - dt_alphas[det_idx] + delta_idx += 1 + + assigned_detection[det_idx] = True + if compute_fp: + for i in range(det_size): + if (not (assigned_detection[i] or ignored_det[i] == -1 + or ignored_det[i] == 1 or ignored_threshold[i])): + fp += 1 + nstuff = 0 + if metric == 0: + overlaps_dt_dc = image_box_overlap(dt_bboxes, dc_bboxes, 0) + for i in range(dc_bboxes.shape[0]): + for j in range(det_size): + if (assigned_detection[j]): + continue + if (ignored_det[j] == -1 or ignored_det[j] == 1): + continue + if (ignored_threshold[j]): + continue + if overlaps_dt_dc[j, i] > min_overlap: + assigned_detection[j] = True + nstuff += 1 + fp -= nstuff + if compute_aos: + tmp = np.zeros((fp + delta_idx, )) + # tmp = [0] * fp + for i in range(delta_idx): + tmp[i + fp] = (1.0 + np.cos(delta[i])) / 2.0 + # tmp.append((1.0 + np.cos(delta[i])) / 2.0) + # assert len(tmp) == fp + tp + # assert len(delta) == tp + if tp > 0 or fp > 0: + similarity = np.sum(tmp) + else: + similarity = -1 + return tp, fp, fn, similarity, thresholds[:thresh_idx] + + +def get_split_parts(num, num_part): + same_part = num // num_part + remain_num = num % num_part + if remain_num == 0: + return [same_part] * num_part + else: + return [same_part] * num_part + [remain_num] + + +@numba.jit(nopython=True) +def fused_compute_statistics(overlaps, + pr, + gt_nums, + dt_nums, + dc_nums, + gt_datas, + dt_datas, + dontcares, + ignored_gts, + ignored_dets, + metric, + min_overlap, + thresholds, + compute_aos=False): + gt_num = 0 + dt_num = 0 + dc_num = 0 + for i in range(gt_nums.shape[0]): + for t, thresh in enumerate(thresholds): + overlap = overlaps[dt_num:dt_num + dt_nums[i], gt_num: + gt_num + gt_nums[i]] + + gt_data = gt_datas[gt_num:gt_num + gt_nums[i]] + dt_data = dt_datas[dt_num:dt_num + dt_nums[i]] + ignored_gt = ignored_gts[gt_num:gt_num + gt_nums[i]] + ignored_det = ignored_dets[dt_num:dt_num + dt_nums[i]] + dontcare = dontcares[dc_num:dc_num + dc_nums[i]] + tp, fp, fn, similarity, _ = compute_statistics_jit( + overlap, + gt_data, + dt_data, + ignored_gt, + ignored_det, + dontcare, + metric, + min_overlap=min_overlap, + thresh=thresh, + compute_fp=True, + compute_aos=compute_aos) + pr[t, 0] += tp + pr[t, 1] += fp + pr[t, 2] += fn + if similarity != -1: + pr[t, 3] += similarity + gt_num += gt_nums[i] + dt_num += dt_nums[i] + dc_num += dc_nums[i] + + +def calculate_iou_partly(gt_annos, dt_annos, metric, num_parts=50): + """fast iou algorithm. this function can be used independently to + do result analysis. Must be used in CAMERA coordinate system. + Args: + gt_annos: dict, must from get_label_annos() in kitti_common.py + dt_annos: dict, must from get_label_annos() in kitti_common.py + metric: eval type. 0: bbox, 1: bev, 2: 3d + num_parts: int. a parameter for fast calculate algorithm + """ + assert len(gt_annos) == len(dt_annos) + total_dt_num = np.stack([len(a["name"]) for a in dt_annos], 0) + total_gt_num = np.stack([len(a["name"]) for a in gt_annos], 0) + num_examples = len(gt_annos) + split_parts = get_split_parts(num_examples, num_parts) + parted_overlaps = [] + example_idx = 0 + + for num_part in split_parts: + gt_annos_part = gt_annos[example_idx:example_idx + num_part] + dt_annos_part = dt_annos[example_idx:example_idx + num_part] + if metric == 0: + gt_boxes = np.concatenate([a["bbox"] for a in gt_annos_part], 0) + dt_boxes = np.concatenate([a["bbox"] for a in dt_annos_part], 0) + overlap_part = image_box_overlap(gt_boxes, dt_boxes) + elif metric == 1: + loc = np.concatenate( + [a["location"][:, [0, 2]] for a in gt_annos_part], 0) + dims = np.concatenate( + [a["dimensions"][:, [0, 2]] for a in gt_annos_part], 0) + rots = np.concatenate([a["rotation_y"] for a in gt_annos_part], 0) + gt_boxes = np.concatenate( + [loc, dims, rots[..., np.newaxis]], axis=1) + loc = np.concatenate( + [a["location"][:, [0, 2]] for a in dt_annos_part], 0) + dims = np.concatenate( + [a["dimensions"][:, [0, 2]] for a in dt_annos_part], 0) + rots = np.concatenate([a["rotation_y"] for a in dt_annos_part], 0) + dt_boxes = np.concatenate( + [loc, dims, rots[..., np.newaxis]], axis=1) + overlap_part = bev_box_overlap(gt_boxes, dt_boxes).astype( + np.float64) + elif metric == 2: + loc = np.concatenate([a["location"] for a in gt_annos_part], 0) + dims = np.concatenate([a["dimensions"] for a in gt_annos_part], 0) + rots = np.concatenate([a["rotation_y"] for a in gt_annos_part], 0) + gt_boxes = np.concatenate( + [loc, dims, rots[..., np.newaxis]], axis=1) + loc = np.concatenate([a["location"] for a in dt_annos_part], 0) + dims = np.concatenate([a["dimensions"] for a in dt_annos_part], 0) + rots = np.concatenate([a["rotation_y"] for a in dt_annos_part], 0) + dt_boxes = np.concatenate( + [loc, dims, rots[..., np.newaxis]], axis=1) + overlap_part = d3_box_overlap(gt_boxes, dt_boxes).astype( + np.float64) + else: + raise ValueError("unknown metric") + parted_overlaps.append(overlap_part) + example_idx += num_part + overlaps = [] + example_idx = 0 + for j, num_part in enumerate(split_parts): + gt_annos_part = gt_annos[example_idx:example_idx + num_part] + dt_annos_part = dt_annos[example_idx:example_idx + num_part] + gt_num_idx, dt_num_idx = 0, 0 + for i in range(num_part): + gt_box_num = total_gt_num[example_idx + i] + dt_box_num = total_dt_num[example_idx + i] + overlaps.append( + parted_overlaps[j][gt_num_idx:gt_num_idx + gt_box_num, + dt_num_idx:dt_num_idx + dt_box_num]) + gt_num_idx += gt_box_num + dt_num_idx += dt_box_num + example_idx += num_part + + return overlaps, parted_overlaps, total_gt_num, total_dt_num + + +def _prepare_data(gt_annos, dt_annos, current_class, difficulty): + gt_datas_list = [] + dt_datas_list = [] + total_dc_num = [] + ignored_gts, ignored_dets, dontcares = [], [], [] + total_num_valid_gt = 0 + for i in range(len(gt_annos)): + rets = clean_data(gt_annos[i], dt_annos[i], current_class, difficulty) + num_valid_gt, ignored_gt, ignored_det, dc_bboxes = rets + ignored_gts.append(np.array(ignored_gt, dtype=np.int64)) + ignored_dets.append(np.array(ignored_det, dtype=np.int64)) + if len(dc_bboxes) == 0: + dc_bboxes = np.zeros((0, 4)).astype(np.float64) + else: + dc_bboxes = np.stack(dc_bboxes, 0).astype(np.float64) + total_dc_num.append(dc_bboxes.shape[0]) + dontcares.append(dc_bboxes) + total_num_valid_gt += num_valid_gt + gt_datas = np.concatenate( + [gt_annos[i]["bbox"], gt_annos[i]["alpha"][..., np.newaxis]], 1) + dt_datas = np.concatenate([ + dt_annos[i]["bbox"], dt_annos[i]["alpha"][..., np.newaxis], + dt_annos[i]["score"][..., np.newaxis] + ], 1) + gt_datas_list.append(gt_datas) + dt_datas_list.append(dt_datas) + total_dc_num = np.stack(total_dc_num, axis=0) + return (gt_datas_list, dt_datas_list, ignored_gts, ignored_dets, dontcares, + total_dc_num, total_num_valid_gt) + + +def eval_class(gt_annos, + dt_annos, + current_classes, + difficultys, + metric, + min_overlaps, + compute_aos=False, + num_parts=50): + """Kitti eval. support 2d/bev/3d/aos eval. support 0.5:0.05:0.95 coco AP. + Args: + gt_annos: dict, must from get_label_annos() in kitti_common.py + dt_annos: dict, must from get_label_annos() in kitti_common.py + current_classes: list of int, 0: car, 1: pedestrian, 2: cyclist + difficultys: list of int. eval difficulty, 0: easy, 1: normal, 2: hard + metric: eval type. 0: bbox, 1: bev, 2: 3d + min_overlaps: float, min overlap. format: [num_overlap, metric, class]. + num_parts: int. a parameter for fast calculate algorithm + + Returns: + dict of recall, precision and aos + """ + assert len(gt_annos) == len(dt_annos) + num_examples = len(gt_annos) + split_parts = get_split_parts(num_examples, num_parts) + + rets = calculate_iou_partly(dt_annos, gt_annos, metric, num_parts) + overlaps, parted_overlaps, total_dt_num, total_gt_num = rets + N_SAMPLE_PTS = 41 + num_minoverlap = len(min_overlaps) + num_class = len(current_classes) + num_difficulty = len(difficultys) + precision = np.zeros( + [num_class, num_difficulty, num_minoverlap, N_SAMPLE_PTS]) + recall = np.zeros( + [num_class, num_difficulty, num_minoverlap, N_SAMPLE_PTS]) + aos = np.zeros([num_class, num_difficulty, num_minoverlap, N_SAMPLE_PTS]) + for m, current_class in enumerate(current_classes): + for l, difficulty in enumerate(difficultys): + rets = _prepare_data(gt_annos, dt_annos, current_class, difficulty) + (gt_datas_list, dt_datas_list, ignored_gts, ignored_dets, + dontcares, total_dc_num, total_num_valid_gt) = rets + for k, min_overlap in enumerate(min_overlaps[:, metric, m]): + thresholdss = [] + for i in range(len(gt_annos)): + rets = compute_statistics_jit( + overlaps[i], + gt_datas_list[i], + dt_datas_list[i], + ignored_gts[i], + ignored_dets[i], + dontcares[i], + metric, + min_overlap=min_overlap, + thresh=0.0, + compute_fp=False) + tp, fp, fn, similarity, thresholds = rets + thresholdss += thresholds.tolist() + thresholdss = np.array(thresholdss) + thresholds = get_thresholds(thresholdss, total_num_valid_gt) + thresholds = np.array(thresholds) + pr = np.zeros([len(thresholds), 4]) + idx = 0 + for j, num_part in enumerate(split_parts): + gt_datas_part = np.concatenate( + gt_datas_list[idx:idx + num_part], 0) + dt_datas_part = np.concatenate( + dt_datas_list[idx:idx + num_part], 0) + dc_datas_part = np.concatenate( + dontcares[idx:idx + num_part], 0) + ignored_dets_part = np.concatenate( + ignored_dets[idx:idx + num_part], 0) + ignored_gts_part = np.concatenate( + ignored_gts[idx:idx + num_part], 0) + fused_compute_statistics( + parted_overlaps[j], + pr, + total_gt_num[idx:idx + num_part], + total_dt_num[idx:idx + num_part], + total_dc_num[idx:idx + num_part], + gt_datas_part, + dt_datas_part, + dc_datas_part, + ignored_gts_part, + ignored_dets_part, + metric, + min_overlap=min_overlap, + thresholds=thresholds, + compute_aos=compute_aos) + idx += num_part + for i in range(len(thresholds)): + recall[m, l, k, i] = pr[i, 0] / (pr[i, 0] + pr[i, 2]) + precision[m, l, k, i] = pr[i, 0] / (pr[i, 0] + pr[i, 1]) + if compute_aos: + aos[m, l, k, i] = pr[i, 3] / (pr[i, 0] + pr[i, 1]) + for i in range(len(thresholds)): + precision[m, l, k, i] = np.max( + precision[m, l, k, i:], axis=-1) + recall[m, l, k, i] = np.max(recall[m, l, k, i:], axis=-1) + if compute_aos: + aos[m, l, k, i] = np.max(aos[m, l, k, i:], axis=-1) + ret_dict = { + "recall": recall, + "precision": precision, + "orientation": aos, + } + return ret_dict + + +def get_mAP(prec): + sums = 0 + for i in range(0, prec.shape[-1], 4): + sums = sums + prec[..., i] + return sums / 11 * 100 + + +def print_str(value, *arg, sstream=None): + if sstream is None: + sstream = sysio.StringIO() + sstream.truncate(0) + sstream.seek(0) + print(value, *arg, file=sstream) + return sstream.getvalue() + + +def do_eval(gt_annos, + dt_annos, + current_classes, + min_overlaps, + compute_aos=False): + # min_overlaps: [num_minoverlap, metric, num_class] + difficultys = [0, 1, 2] + ret = eval_class(gt_annos, dt_annos, current_classes, difficultys, 0, + min_overlaps, compute_aos) + # ret: [num_class, num_diff, num_minoverlap, num_sample_points] + mAP_bbox = get_mAP(ret["precision"]) + mAP_aos = None + if compute_aos: + mAP_aos = get_mAP(ret["orientation"]) + ret = eval_class(gt_annos, dt_annos, current_classes, difficultys, 1, + min_overlaps) + mAP_bev = get_mAP(ret["precision"]) + ret = eval_class(gt_annos, dt_annos, current_classes, difficultys, 2, + min_overlaps) + mAP_3d = get_mAP(ret["precision"]) + return mAP_bbox, mAP_bev, mAP_3d, mAP_aos + + +def do_coco_style_eval(gt_annos, dt_annos, current_classes, overlap_ranges, + compute_aos): + # overlap_ranges: [range, metric, num_class] + min_overlaps = np.zeros([10, *overlap_ranges.shape[1:]]) + for i in range(overlap_ranges.shape[1]): + for j in range(overlap_ranges.shape[2]): + min_overlaps[:, i, j] = np.linspace(*overlap_ranges[:, i, j]) + mAP_bbox, mAP_bev, mAP_3d, mAP_aos = do_eval( + gt_annos, dt_annos, current_classes, min_overlaps, compute_aos) + # ret: [num_class, num_diff, num_minoverlap] + mAP_bbox = mAP_bbox.mean(-1) + mAP_bev = mAP_bev.mean(-1) + mAP_3d = mAP_3d.mean(-1) + if mAP_aos is not None: + mAP_aos = mAP_aos.mean(-1) + return mAP_bbox, mAP_bev, mAP_3d, mAP_aos + + +def get_official_eval_result(gt_annos, dt_annos, current_classes): + overlap_0_7 = np.array([[0.7, 0.5, 0.5, 0.7, + 0.5], [0.7, 0.5, 0.5, 0.7, 0.5], + [0.7, 0.5, 0.5, 0.7, 0.5]]) + overlap_0_5 = np.array([[0.7, 0.5, 0.5, 0.7, + 0.5], [0.5, 0.25, 0.25, 0.5, 0.25], + [0.5, 0.25, 0.25, 0.5, 0.25]]) + min_overlaps = np.stack([overlap_0_7, overlap_0_5], axis=0) # [2, 3, 5] + class_to_name = { + 0: 'Car', + 1: 'Pedestrian', + 2: 'Cyclist', + 3: 'Van', + 4: 'Person_sitting', + } + name_to_class = {v: n for n, v in class_to_name.items()} + if not isinstance(current_classes, (list, tuple)): + current_classes = [current_classes] + current_classes_int = [] + for curcls in current_classes: + if isinstance(curcls, str): + current_classes_int.append(name_to_class[curcls]) + else: + current_classes_int.append(curcls) + current_classes = current_classes_int + min_overlaps = min_overlaps[:, :, current_classes] + result = '' + # check whether alpha is valid + compute_aos = False + for anno in dt_annos: + if anno['alpha'].shape[0] != 0: + if anno['alpha'][0] != -10: + compute_aos = True + break + mAPbbox, mAPbev, mAP3d, mAPaos = do_eval( + gt_annos, dt_annos, current_classes, min_overlaps, compute_aos) + + ret_dict = {} + for j, curcls in enumerate(current_classes): + # mAP threshold array: [num_minoverlap, metric, class] + # mAP result: [num_class, num_diff, num_minoverlap] + for i in range(min_overlaps.shape[0]): + result += print_str( + (f"{class_to_name[curcls]} " + "AP@{:.2f}, {:.2f}, {:.2f}:".format(*min_overlaps[i, :, j]))) + result += print_str((f"bbox AP:{mAPbbox[j, 0, i]:.4f}, " + f"{mAPbbox[j, 1, i]:.4f}, " + f"{mAPbbox[j, 2, i]:.4f}")) + result += print_str((f"bev AP:{mAPbev[j, 0, i]:.4f}, " + f"{mAPbev[j, 1, i]:.4f}, " + f"{mAPbev[j, 2, i]:.4f}")) + result += print_str((f"3d AP:{mAP3d[j, 0, i]:.4f}, " + f"{mAP3d[j, 1, i]:.4f}, " + f"{mAP3d[j, 2, i]:.4f}")) + + + if compute_aos: + result += print_str((f"aos AP:{mAPaos[j, 0, i]:.2f}, " + f"{mAPaos[j, 1, i]:.2f}, " + f"{mAPaos[j, 2, i]:.2f}")) + ret_dict['Car_3d_easy'] = mAP3d[0, 0, 0] + ret_dict['Car_3d_moderate'] = mAP3d[0, 1, 0] + ret_dict['Car_3d_hard'] = mAP3d[0, 2, 0] + ret_dict['Car_bev_easy'] = mAPbev[0, 0, 0] + ret_dict['Car_bev_moderate'] = mAPbev[0, 1, 0] + ret_dict['Car_bev_hard'] = mAPbev[0, 2, 0] + ret_dict['Car_image_easy'] = mAPbbox[0, 0, 0] + ret_dict['Car_image_moderate'] = mAPbbox[0, 1, 0] + ret_dict['Car_image_hard'] = mAPbbox[0, 2, 0] + + return result, ret_dict + + +def get_coco_eval_result(gt_annos, dt_annos, current_classes): + class_to_name = { + 0: 'Car', + 1: 'Pedestrian', + 2: 'Cyclist', + 3: 'Van', + 4: 'Person_sitting', + } + class_to_range = { + 0: [0.5, 0.95, 10], + 1: [0.25, 0.7, 10], + 2: [0.25, 0.7, 10], + 3: [0.5, 0.95, 10], + 4: [0.25, 0.7, 10], + } + name_to_class = {v: n for n, v in class_to_name.items()} + if not isinstance(current_classes, (list, tuple)): + current_classes = [current_classes] + current_classes_int = [] + for curcls in current_classes: + if isinstance(curcls, str): + current_classes_int.append(name_to_class[curcls]) + else: + current_classes_int.append(curcls) + current_classes = current_classes_int + overlap_ranges = np.zeros([3, 3, len(current_classes)]) + for i, curcls in enumerate(current_classes): + overlap_ranges[:, :, i] = np.array( + class_to_range[curcls])[:, np.newaxis] + result = '' + # check whether alpha is valid + compute_aos = False + for anno in dt_annos: + if anno['alpha'].shape[0] != 0: + if anno['alpha'][0] != -10: + compute_aos = True + break + mAPbbox, mAPbev, mAP3d, mAPaos = do_coco_style_eval( + gt_annos, dt_annos, current_classes, overlap_ranges, compute_aos) + for j, curcls in enumerate(current_classes): + # mAP threshold array: [num_minoverlap, metric, class] + # mAP result: [num_class, num_diff, num_minoverlap] + o_range = np.array(class_to_range[curcls])[[0, 2, 1]] + o_range[1] = (o_range[2] - o_range[0]) / (o_range[1] - 1) + result += print_str((f"{class_to_name[curcls]} " + "coco AP@{:.2f}:{:.2f}:{:.2f}:".format(*o_range))) + result += print_str((f"bbox AP:{mAPbbox[j, 0]:.2f}, " + f"{mAPbbox[j, 1]:.2f}, " + f"{mAPbbox[j, 2]:.2f}")) + result += print_str((f"bev AP:{mAPbev[j, 0]:.2f}, " + f"{mAPbev[j, 1]:.2f}, " + f"{mAPbev[j, 2]:.2f}")) + result += print_str((f"3d AP:{mAP3d[j, 0]:.2f}, " + f"{mAP3d[j, 1]:.2f}, " + f"{mAP3d[j, 2]:.2f}")) + if compute_aos: + result += print_str((f"aos AP:{mAPaos[j, 0]:.2f}, " + f"{mAPaos[j, 1]:.2f}, " + f"{mAPaos[j, 2]:.2f}")) + return result diff --git a/PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/evaluate.py b/PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/evaluate.py new file mode 100644 index 00000000..e822ae46 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/evaluate.py @@ -0,0 +1,32 @@ +import time +import fire + +import tools.kitti_object_eval_python.kitti_common as kitti +from tools.kitti_object_eval_python.eval import get_official_eval_result, get_coco_eval_result + + +def _read_imageset_file(path): + with open(path, 'r') as f: + lines = f.readlines() + return [int(line) for line in lines] + + +def evaluate(label_path, + result_path, + label_split_file, + current_class=0, + coco=False, + score_thresh=-1): + dt_annos = kitti.get_label_annos(result_path) + if score_thresh > 0: + dt_annos = kitti.filter_annos_low_score(dt_annos, score_thresh) + val_image_ids = _read_imageset_file(label_split_file) + gt_annos = kitti.get_label_annos(label_path, val_image_ids) + if coco: + return get_coco_eval_result(gt_annos, dt_annos, current_class) + else: + return get_official_eval_result(gt_annos, dt_annos, current_class) + + +if __name__ == '__main__': + fire.Fire() diff --git a/PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/kitti_common.py b/PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/kitti_common.py new file mode 100644 index 00000000..e7e254ea --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/kitti_common.py @@ -0,0 +1,411 @@ +import concurrent.futures as futures +import os +import pathlib +import re +from collections import OrderedDict + +import numpy as np +from skimage import io + +def get_image_index_str(img_idx): + return "{:06d}".format(img_idx) + + +def get_kitti_info_path(idx, + prefix, + info_type='image_2', + file_tail='.png', + training=True, + relative_path=True): + img_idx_str = get_image_index_str(idx) + img_idx_str += file_tail + prefix = pathlib.Path(prefix) + if training: + file_path = pathlib.Path('training') / info_type / img_idx_str + else: + file_path = pathlib.Path('testing') / info_type / img_idx_str + if not (prefix / file_path).exists(): + raise ValueError("file not exist: {}".format(file_path)) + if relative_path: + return str(file_path) + else: + return str(prefix / file_path) + + +def get_image_path(idx, prefix, training=True, relative_path=True): + return get_kitti_info_path(idx, prefix, 'image_2', '.png', training, + relative_path) + + +def get_label_path(idx, prefix, training=True, relative_path=True): + return get_kitti_info_path(idx, prefix, 'label_2', '.txt', training, + relative_path) + + +def get_velodyne_path(idx, prefix, training=True, relative_path=True): + return get_kitti_info_path(idx, prefix, 'velodyne', '.bin', training, + relative_path) + + +def get_calib_path(idx, prefix, training=True, relative_path=True): + return get_kitti_info_path(idx, prefix, 'calib', '.txt', training, + relative_path) + + +def _extend_matrix(mat): + mat = np.concatenate([mat, np.array([[0., 0., 0., 1.]])], axis=0) + return mat + + +def get_kitti_image_info(path, + training=True, + label_info=True, + velodyne=False, + calib=False, + image_ids=7481, + extend_matrix=True, + num_worker=8, + relative_path=True, + with_imageshape=True): + # image_infos = [] + root_path = pathlib.Path(path) + if not isinstance(image_ids, list): + image_ids = list(range(image_ids)) + + def map_func(idx): + image_info = {'image_idx': idx} + annotations = None + if velodyne: + image_info['velodyne_path'] = get_velodyne_path( + idx, path, training, relative_path) + image_info['img_path'] = get_image_path(idx, path, training, + relative_path) + if with_imageshape: + img_path = image_info['img_path'] + if relative_path: + img_path = str(root_path / img_path) + image_info['img_shape'] = np.array( + io.imread(img_path).shape[:2], dtype=np.int32) + if label_info: + 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 calib: + calib_path = get_calib_path( + idx, path, training, relative_path=False) + with open(calib_path, 'r') as f: + lines = f.readlines() + P0 = np.array( + [float(info) for info in lines[0].split(' ')[1:13]]).reshape( + [3, 4]) + P1 = np.array( + [float(info) for info in lines[1].split(' ')[1:13]]).reshape( + [3, 4]) + P2 = np.array( + [float(info) for info in lines[2].split(' ')[1:13]]).reshape( + [3, 4]) + P3 = np.array( + [float(info) for info in lines[3].split(' ')[1:13]]).reshape( + [3, 4]) + if extend_matrix: + P0 = _extend_matrix(P0) + P1 = _extend_matrix(P1) + P2 = _extend_matrix(P2) + P3 = _extend_matrix(P3) + image_info['calib/P0'] = P0 + image_info['calib/P1'] = P1 + image_info['calib/P2'] = P2 + image_info['calib/P3'] = P3 + R0_rect = np.array([ + float(info) for info in lines[4].split(' ')[1:10] + ]).reshape([3, 3]) + if extend_matrix: + rect_4x4 = np.zeros([4, 4], dtype=R0_rect.dtype) + rect_4x4[3, 3] = 1. + rect_4x4[:3, :3] = R0_rect + else: + rect_4x4 = R0_rect + image_info['calib/R0_rect'] = rect_4x4 + Tr_velo_to_cam = np.array([ + float(info) for info in lines[5].split(' ')[1:13] + ]).reshape([3, 4]) + Tr_imu_to_velo = np.array([ + float(info) for info in lines[6].split(' ')[1:13] + ]).reshape([3, 4]) + if extend_matrix: + Tr_velo_to_cam = _extend_matrix(Tr_velo_to_cam) + Tr_imu_to_velo = _extend_matrix(Tr_imu_to_velo) + image_info['calib/Tr_velo_to_cam'] = Tr_velo_to_cam + image_info['calib/Tr_imu_to_velo'] = Tr_imu_to_velo + if annotations is not None: + image_info['annos'] = annotations + add_difficulty_to_annos(image_info) + return image_info + + with futures.ThreadPoolExecutor(num_worker) as executor: + image_infos = executor.map(map_func, image_ids) + return list(image_infos) + + +def filter_kitti_anno(image_anno, + used_classes, + used_difficulty=None, + dontcare_iou=None): + if not isinstance(used_classes, (list, tuple)): + used_classes = [used_classes] + img_filtered_annotations = {} + relevant_annotation_indices = [ + i for i, x in enumerate(image_anno['name']) if x in used_classes + ] + for key in image_anno.keys(): + img_filtered_annotations[key] = ( + image_anno[key][relevant_annotation_indices]) + if used_difficulty is not None: + relevant_annotation_indices = [ + i for i, x in enumerate(img_filtered_annotations['difficulty']) + if x in used_difficulty + ] + for key in image_anno.keys(): + img_filtered_annotations[key] = ( + img_filtered_annotations[key][relevant_annotation_indices]) + + if 'DontCare' in used_classes and dontcare_iou is not None: + dont_care_indices = [ + i for i, x in enumerate(img_filtered_annotations['name']) + if x == 'DontCare' + ] + # bounding box format [y_min, x_min, y_max, x_max] + all_boxes = img_filtered_annotations['bbox'] + ious = iou(all_boxes, all_boxes[dont_care_indices]) + + # Remove all bounding boxes that overlap with a dontcare region. + if ious.size > 0: + boxes_to_remove = np.amax(ious, axis=1) > dontcare_iou + for key in image_anno.keys(): + img_filtered_annotations[key] = (img_filtered_annotations[key][ + np.logical_not(boxes_to_remove)]) + return img_filtered_annotations + +def filter_annos_low_score(image_annos, thresh): + new_image_annos = [] + for anno in image_annos: + img_filtered_annotations = {} + relevant_annotation_indices = [ + i for i, s in enumerate(anno['score']) if s >= thresh + ] + for key in anno.keys(): + img_filtered_annotations[key] = ( + anno[key][relevant_annotation_indices]) + new_image_annos.append(img_filtered_annotations) + return new_image_annos + +def kitti_result_line(result_dict, precision=4): + prec_float = "{" + ":.{}f".format(precision) + "}" + res_line = [] + all_field_default = OrderedDict([ + ('name', None), + ('truncated', -1), + ('occluded', -1), + ('alpha', -10), + ('bbox', None), + ('dimensions', [-1, -1, -1]), + ('location', [-1000, -1000, -1000]), + ('rotation_y', -10), + ('score', None), + ]) + res_dict = [(key, None) for key, val in all_field_default.items()] + res_dict = OrderedDict(res_dict) + for key, val in result_dict.items(): + if all_field_default[key] is None and val is None: + raise ValueError("you must specify a value for {}".format(key)) + res_dict[key] = val + + for key, val in res_dict.items(): + if key == 'name': + res_line.append(val) + elif key in ['truncated', 'alpha', 'rotation_y', 'score']: + if val is None: + res_line.append(str(all_field_default[key])) + else: + res_line.append(prec_float.format(val)) + elif key == 'occluded': + if val is None: + res_line.append(str(all_field_default[key])) + else: + res_line.append('{}'.format(val)) + elif key in ['bbox', 'dimensions', 'location']: + if val is None: + res_line += [str(v) for v in all_field_default[key]] + else: + res_line += [prec_float.format(v) for v in val] + else: + raise ValueError("unknown key. supported key:{}".format( + res_dict.keys())) + return ' '.join(res_line) + + +def add_difficulty_to_annos(info): + min_height = [40, 25, + 25] # minimum height for evaluated groundtruth/detections + max_occlusion = [ + 0, 1, 2 + ] # maximum occlusion level of the groundtruth used for evaluation + max_trunc = [ + 0.15, 0.3, 0.5 + ] # maximum truncation level of the groundtruth used for evaluation + annos = info['annos'] + dims = annos['dimensions'] # lhw format + bbox = annos['bbox'] + height = bbox[:, 3] - bbox[:, 1] + occlusion = annos['occluded'] + truncation = annos['truncated'] + diff = [] + easy_mask = np.ones((len(dims), ), dtype=np.bool) + moderate_mask = np.ones((len(dims), ), dtype=np.bool) + hard_mask = np.ones((len(dims), ), dtype=np.bool) + i = 0 + for h, o, t in zip(height, occlusion, truncation): + if o > max_occlusion[0] or h <= min_height[0] or t > max_trunc[0]: + easy_mask[i] = False + if o > max_occlusion[1] or h <= min_height[1] or t > max_trunc[1]: + moderate_mask[i] = False + if o > max_occlusion[2] or h <= min_height[2] or t > max_trunc[2]: + hard_mask[i] = False + i += 1 + is_easy = easy_mask + is_moderate = np.logical_xor(easy_mask, moderate_mask) + is_hard = np.logical_xor(hard_mask, moderate_mask) + + for i in range(len(dims)): + if is_easy[i]: + diff.append(0) + elif is_moderate[i]: + diff.append(1) + elif is_hard[i]: + diff.append(2) + else: + diff.append(-1) + annos["difficulty"] = np.array(diff, np.int32) + return diff + + +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] + annotations['name'] = np.array([x[0] for x in content]) + annotations['truncated'] = np.array([float(x[1]) for x in content]) + annotations['occluded'] = np.array([int(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([len(annotations['bbox'])]) + return annotations + +def get_label_annos(label_folder, image_ids=None): + if image_ids is None: + filepaths = pathlib.Path(label_folder).glob('*.txt') + prog = re.compile(r'^\d{6}.txt$') + filepaths = filter(lambda f: prog.match(f.name), filepaths) + image_ids = [int(p.stem) for p in filepaths] + image_ids = sorted(image_ids) + if not isinstance(image_ids, list): + image_ids = list(range(image_ids)) + annos = [] + label_folder = pathlib.Path(label_folder) + for idx in image_ids: + image_idx = get_image_index_str(idx) + label_filename = label_folder / (image_idx + '.txt') + annos.append(get_label_anno(label_filename)) + return annos + +def area(boxes, add1=False): + """Computes area of boxes. + + Args: + boxes: Numpy array with shape [N, 4] holding N boxes + + Returns: + a numpy array with shape [N*1] representing box areas + """ + if add1: + return (boxes[:, 2] - boxes[:, 0] + 1.0) * ( + boxes[:, 3] - boxes[:, 1] + 1.0) + else: + return (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1]) + + +def intersection(boxes1, boxes2, add1=False): + """Compute pairwise intersection areas between boxes. + + Args: + boxes1: a numpy array with shape [N, 4] holding N boxes + boxes2: a numpy array with shape [M, 4] holding M boxes + + Returns: + a numpy array with shape [N*M] representing pairwise intersection area + """ + [y_min1, x_min1, y_max1, x_max1] = np.split(boxes1, 4, axis=1) + [y_min2, x_min2, y_max2, x_max2] = np.split(boxes2, 4, axis=1) + + all_pairs_min_ymax = np.minimum(y_max1, np.transpose(y_max2)) + all_pairs_max_ymin = np.maximum(y_min1, np.transpose(y_min2)) + if add1: + all_pairs_min_ymax += 1.0 + intersect_heights = np.maximum( + np.zeros(all_pairs_max_ymin.shape), + all_pairs_min_ymax - all_pairs_max_ymin) + + all_pairs_min_xmax = np.minimum(x_max1, np.transpose(x_max2)) + all_pairs_max_xmin = np.maximum(x_min1, np.transpose(x_min2)) + if add1: + all_pairs_min_xmax += 1.0 + intersect_widths = np.maximum( + np.zeros(all_pairs_max_xmin.shape), + all_pairs_min_xmax - all_pairs_max_xmin) + return intersect_heights * intersect_widths + + +def iou(boxes1, boxes2, add1=False): + """Computes pairwise intersection-over-union between box collections. + + Args: + boxes1: a numpy array with shape [N, 4] holding N boxes. + boxes2: a numpy array with shape [M, 4] holding N boxes. + + Returns: + a numpy array with shape [N, M] representing pairwise iou scores. + """ + intersect = intersection(boxes1, boxes2, add1) + area1 = area(boxes1, add1) + area2 = area(boxes2, add1) + union = np.expand_dims( + area1, axis=1) + np.expand_dims( + area2, axis=0) - intersect + return intersect / union diff --git a/PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/rotate_iou.py b/PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/rotate_iou.py new file mode 100644 index 00000000..cd694ef5 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/tools/kitti_object_eval_python/rotate_iou.py @@ -0,0 +1,329 @@ +##################### +# Based on https://github.com/hongzhenwang/RRPN-revise +# Licensed under The MIT License +# Author: yanyan, scrin@foxmail.com +##################### +import math + +import numba +import numpy as np +from numba import cuda + +@numba.jit(nopython=True) +def div_up(m, n): + return m // n + (m % n > 0) + +@cuda.jit('(float32[:], float32[:], float32[:])', device=True, inline=True) +def trangle_area(a, b, c): + return ((a[0] - c[0]) * (b[1] - c[1]) - (a[1] - c[1]) * + (b[0] - c[0])) / 2.0 + + +@cuda.jit('(float32[:], int32)', device=True, inline=True) +def area(int_pts, num_of_inter): + area_val = 0.0 + for i in range(num_of_inter - 2): + area_val += abs( + trangle_area(int_pts[:2], int_pts[2 * i + 2:2 * i + 4], + int_pts[2 * i + 4:2 * i + 6])) + return area_val + + +@cuda.jit('(float32[:], int32)', device=True, inline=True) +def sort_vertex_in_convex_polygon(int_pts, num_of_inter): + if num_of_inter > 0: + center = cuda.local.array((2, ), dtype=numba.float32) + center[:] = 0.0 + for i in range(num_of_inter): + center[0] += int_pts[2 * i] + center[1] += int_pts[2 * i + 1] + center[0] /= num_of_inter + center[1] /= num_of_inter + v = cuda.local.array((2, ), dtype=numba.float32) + vs = cuda.local.array((16, ), dtype=numba.float32) + for i in range(num_of_inter): + v[0] = int_pts[2 * i] - center[0] + v[1] = int_pts[2 * i + 1] - center[1] + d = math.sqrt(v[0] * v[0] + v[1] * v[1]) + v[0] = v[0] / d + v[1] = v[1] / d + if v[1] < 0: + v[0] = -2 - v[0] + vs[i] = v[0] + j = 0 + temp = 0 + for i in range(1, num_of_inter): + if vs[i - 1] > vs[i]: + temp = vs[i] + tx = int_pts[2 * i] + ty = int_pts[2 * i + 1] + j = i + while j > 0 and vs[j - 1] > temp: + vs[j] = vs[j - 1] + int_pts[j * 2] = int_pts[j * 2 - 2] + int_pts[j * 2 + 1] = int_pts[j * 2 - 1] + j -= 1 + + vs[j] = temp + int_pts[j * 2] = tx + int_pts[j * 2 + 1] = ty + + +@cuda.jit( + '(float32[:], float32[:], int32, int32, float32[:])', + device=True, + inline=True) +def line_segment_intersection(pts1, pts2, i, j, temp_pts): + A = cuda.local.array((2, ), dtype=numba.float32) + B = cuda.local.array((2, ), dtype=numba.float32) + C = cuda.local.array((2, ), dtype=numba.float32) + D = cuda.local.array((2, ), dtype=numba.float32) + + A[0] = pts1[2 * i] + A[1] = pts1[2 * i + 1] + + B[0] = pts1[2 * ((i + 1) % 4)] + B[1] = pts1[2 * ((i + 1) % 4) + 1] + + C[0] = pts2[2 * j] + C[1] = pts2[2 * j + 1] + + D[0] = pts2[2 * ((j + 1) % 4)] + D[1] = pts2[2 * ((j + 1) % 4) + 1] + BA0 = B[0] - A[0] + BA1 = B[1] - A[1] + DA0 = D[0] - A[0] + CA0 = C[0] - A[0] + DA1 = D[1] - A[1] + CA1 = C[1] - A[1] + acd = DA1 * CA0 > CA1 * DA0 + bcd = (D[1] - B[1]) * (C[0] - B[0]) > (C[1] - B[1]) * (D[0] - B[0]) + if acd != bcd: + abc = CA1 * BA0 > BA1 * CA0 + abd = DA1 * BA0 > BA1 * DA0 + if abc != abd: + DC0 = D[0] - C[0] + DC1 = D[1] - C[1] + ABBA = A[0] * B[1] - B[0] * A[1] + CDDC = C[0] * D[1] - D[0] * C[1] + DH = BA1 * DC0 - BA0 * DC1 + Dx = ABBA * DC0 - BA0 * CDDC + Dy = ABBA * DC1 - BA1 * CDDC + temp_pts[0] = Dx / DH + temp_pts[1] = Dy / DH + return True + return False + + +@cuda.jit( + '(float32[:], float32[:], int32, int32, float32[:])', + device=True, + inline=True) +def line_segment_intersection_v1(pts1, pts2, i, j, temp_pts): + a = cuda.local.array((2, ), dtype=numba.float32) + b = cuda.local.array((2, ), dtype=numba.float32) + c = cuda.local.array((2, ), dtype=numba.float32) + d = cuda.local.array((2, ), dtype=numba.float32) + + a[0] = pts1[2 * i] + a[1] = pts1[2 * i + 1] + + b[0] = pts1[2 * ((i + 1) % 4)] + b[1] = pts1[2 * ((i + 1) % 4) + 1] + + c[0] = pts2[2 * j] + c[1] = pts2[2 * j + 1] + + d[0] = pts2[2 * ((j + 1) % 4)] + d[1] = pts2[2 * ((j + 1) % 4) + 1] + + area_abc = trangle_area(a, b, c) + area_abd = trangle_area(a, b, d) + + if area_abc * area_abd >= 0: + return False + + area_cda = trangle_area(c, d, a) + area_cdb = area_cda + area_abc - area_abd + + if area_cda * area_cdb >= 0: + return False + t = area_cda / (area_abd - area_abc) + + dx = t * (b[0] - a[0]) + dy = t * (b[1] - a[1]) + temp_pts[0] = a[0] + dx + temp_pts[1] = a[1] + dy + return True + + +@cuda.jit('(float32, float32, float32[:])', device=True, inline=True) +def point_in_quadrilateral(pt_x, pt_y, corners): + ab0 = corners[2] - corners[0] + ab1 = corners[3] - corners[1] + + ad0 = corners[6] - corners[0] + ad1 = corners[7] - corners[1] + + ap0 = pt_x - corners[0] + ap1 = pt_y - corners[1] + + abab = ab0 * ab0 + ab1 * ab1 + abap = ab0 * ap0 + ab1 * ap1 + adad = ad0 * ad0 + ad1 * ad1 + adap = ad0 * ap0 + ad1 * ap1 + + return abab >= abap and abap >= 0 and adad >= adap and adap >= 0 + + +@cuda.jit('(float32[:], float32[:], float32[:])', device=True, inline=True) +def quadrilateral_intersection(pts1, pts2, int_pts): + num_of_inter = 0 + for i in range(4): + if point_in_quadrilateral(pts1[2 * i], pts1[2 * i + 1], pts2): + int_pts[num_of_inter * 2] = pts1[2 * i] + int_pts[num_of_inter * 2 + 1] = pts1[2 * i + 1] + num_of_inter += 1 + if point_in_quadrilateral(pts2[2 * i], pts2[2 * i + 1], pts1): + int_pts[num_of_inter * 2] = pts2[2 * i] + int_pts[num_of_inter * 2 + 1] = pts2[2 * i + 1] + num_of_inter += 1 + temp_pts = cuda.local.array((2, ), dtype=numba.float32) + for i in range(4): + for j in range(4): + has_pts = line_segment_intersection(pts1, pts2, i, j, temp_pts) + if has_pts: + int_pts[num_of_inter * 2] = temp_pts[0] + int_pts[num_of_inter * 2 + 1] = temp_pts[1] + num_of_inter += 1 + + return num_of_inter + + +@cuda.jit('(float32[:], float32[:])', device=True, inline=True) +def rbbox_to_corners(corners, rbbox): + # generate clockwise corners and rotate it clockwise + angle = rbbox[4] + a_cos = math.cos(angle) + a_sin = math.sin(angle) + center_x = rbbox[0] + center_y = rbbox[1] + x_d = rbbox[2] + y_d = rbbox[3] + corners_x = cuda.local.array((4, ), dtype=numba.float32) + corners_y = cuda.local.array((4, ), dtype=numba.float32) + corners_x[0] = -x_d / 2 + corners_x[1] = -x_d / 2 + corners_x[2] = x_d / 2 + corners_x[3] = x_d / 2 + corners_y[0] = -y_d / 2 + corners_y[1] = y_d / 2 + corners_y[2] = y_d / 2 + corners_y[3] = -y_d / 2 + for i in range(4): + corners[2 * + i] = a_cos * corners_x[i] + a_sin * corners_y[i] + center_x + corners[2 * i + + 1] = -a_sin * corners_x[i] + a_cos * corners_y[i] + center_y + + +@cuda.jit('(float32[:], float32[:])', device=True, inline=True) +def inter(rbbox1, rbbox2): + corners1 = cuda.local.array((8, ), dtype=numba.float32) + corners2 = cuda.local.array((8, ), dtype=numba.float32) + intersection_corners = cuda.local.array((16, ), dtype=numba.float32) + + rbbox_to_corners(corners1, rbbox1) + rbbox_to_corners(corners2, rbbox2) + + num_intersection = quadrilateral_intersection(corners1, corners2, + intersection_corners) + sort_vertex_in_convex_polygon(intersection_corners, num_intersection) + # print(intersection_corners.reshape([-1, 2])[:num_intersection]) + + return area(intersection_corners, num_intersection) + + +@cuda.jit('(float32[:], float32[:], int32)', device=True, inline=True) +def devRotateIoUEval(rbox1, rbox2, criterion=-1): + area1 = rbox1[2] * rbox1[3] + area2 = rbox2[2] * rbox2[3] + area_inter = inter(rbox1, rbox2) + if criterion == -1: + return area_inter / (area1 + area2 - area_inter) + elif criterion == 0: + return area_inter / area1 + elif criterion == 1: + return area_inter / area2 + else: + return area_inter + +@cuda.jit('(int64, int64, float32[:], float32[:], float32[:], int32)', fastmath=False) +def rotate_iou_kernel_eval(N, K, dev_boxes, dev_query_boxes, dev_iou, criterion=-1): + threadsPerBlock = 8 * 8 + row_start = cuda.blockIdx.x + col_start = cuda.blockIdx.y + tx = cuda.threadIdx.x + row_size = min(N - row_start * threadsPerBlock, threadsPerBlock) + col_size = min(K - col_start * threadsPerBlock, threadsPerBlock) + block_boxes = cuda.shared.array(shape=(64 * 5, ), dtype=numba.float32) + block_qboxes = cuda.shared.array(shape=(64 * 5, ), dtype=numba.float32) + + dev_query_box_idx = threadsPerBlock * col_start + tx + dev_box_idx = threadsPerBlock * row_start + tx + if (tx < col_size): + block_qboxes[tx * 5 + 0] = dev_query_boxes[dev_query_box_idx * 5 + 0] + block_qboxes[tx * 5 + 1] = dev_query_boxes[dev_query_box_idx * 5 + 1] + block_qboxes[tx * 5 + 2] = dev_query_boxes[dev_query_box_idx * 5 + 2] + block_qboxes[tx * 5 + 3] = dev_query_boxes[dev_query_box_idx * 5 + 3] + block_qboxes[tx * 5 + 4] = dev_query_boxes[dev_query_box_idx * 5 + 4] + if (tx < row_size): + block_boxes[tx * 5 + 0] = dev_boxes[dev_box_idx * 5 + 0] + block_boxes[tx * 5 + 1] = dev_boxes[dev_box_idx * 5 + 1] + block_boxes[tx * 5 + 2] = dev_boxes[dev_box_idx * 5 + 2] + block_boxes[tx * 5 + 3] = dev_boxes[dev_box_idx * 5 + 3] + block_boxes[tx * 5 + 4] = dev_boxes[dev_box_idx * 5 + 4] + cuda.syncthreads() + if tx < row_size: + for i in range(col_size): + offset = row_start * threadsPerBlock * K + col_start * threadsPerBlock + tx * K + i + dev_iou[offset] = devRotateIoUEval(block_qboxes[i * 5:i * 5 + 5], + block_boxes[tx * 5:tx * 5 + 5], criterion) + + +def rotate_iou_gpu_eval(boxes, query_boxes, criterion=-1, device_id=0): + """rotated box iou running in gpu. 500x faster than cpu version + (take 5ms in one example with numba.cuda code). + convert from [this project]( + https://github.com/hongzhenwang/RRPN-revise/tree/master/lib/rotation). + + Args: + boxes (float tensor: [N, 5]): rbboxes. format: centers, dims, + angles(clockwise when positive) + query_boxes (float tensor: [K, 5]): [description] + device_id (int, optional): Defaults to 0. [description] + + Returns: + [type]: [description] + """ + box_dtype = boxes.dtype + boxes = boxes.astype(np.float32) + query_boxes = query_boxes.astype(np.float32) + N = boxes.shape[0] + K = query_boxes.shape[0] + iou = np.zeros((N, K), dtype=np.float32) + if N == 0 or K == 0: + return iou + threadsPerBlock = 8 * 8 + cuda.select_device(device_id) + blockspergrid = (div_up(N, threadsPerBlock), div_up(K, threadsPerBlock)) + + stream = cuda.stream() + with stream.auto_synchronize(): + boxes_dev = cuda.to_device(boxes.reshape([-1]), stream) + query_boxes_dev = cuda.to_device(query_boxes.reshape([-1]), stream) + iou_dev = cuda.to_device(iou.reshape([-1]), stream) + rotate_iou_kernel_eval[blockspergrid, threadsPerBlock, stream]( + N, K, boxes_dev, query_boxes_dev, iou_dev, criterion) + iou_dev.copy_to_host(iou.reshape([-1]), stream=stream) + return iou.astype(boxes.dtype) diff --git a/PaddleCV/Paddle3D/PointRCNN/train.py b/PaddleCV/Paddle3D/PointRCNN/train.py new file mode 100644 index 00000000..b7a39ca4 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/train.py @@ -0,0 +1,240 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +#Licensed under the Apache License, Version 2.0 (the "License"); +#you may not use this file except in compliance with the License. +#You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +#Unless required by applicable law or agreed to in writing, software +#distributed under the License is distributed on an "AS IS" BASIS, +#WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#See the License for the specific language governing permissions and +#limitations under the License. + +import os +import sys +import time +import shutil +import argparse +import logging +import numpy as np +import paddle +import paddle.fluid as fluid +from paddle.fluid.layers import control_flow +from paddle.fluid.contrib.extend_optimizer import extend_with_decoupled_weight_decay +import paddle.fluid.layers.learning_rate_scheduler as lr_scheduler + +from models.point_rcnn import PointRCNN +from data.kitti_rcnn_reader import KittiRCNNReader +from utils.run_utils import * +from utils.config import cfg, load_config, set_config_from_list +from utils.optimizer import optimize + +logging.root.handlers = [] +FORMAT = '%(asctime)s-%(levelname)s: %(message)s' +logging.basicConfig(level=logging.INFO, format=FORMAT, stream=sys.stdout) +logger = logging.getLogger(__name__) + + +def parse_args(): + parser = argparse.ArgumentParser("PointRCNN semantic segmentation train script") + parser.add_argument( + '--cfg', + type=str, + default='cfgs/default.yml', + help='specify the config for training') + parser.add_argument( + '--train_mode', + type=str, + default='rpn', + required=True, + help='specify the training mode') + parser.add_argument( + '--batch_size', + type=int, + default=16, + required=True, + help='training batch size, default 16') + parser.add_argument( + '--epoch', + type=int, + default=200, + required=True, + help='epoch number. default 200.') + parser.add_argument( + '--save_dir', + type=str, + default='checkpoints', + help='directory name to save train snapshoot') + parser.add_argument( + '--resume', + type=str, + default=None, + help='path to resume training based on previous checkpoints. ' + 'None for not resuming any checkpoints.') + parser.add_argument( + '--resume_epoch', + type=int, + default=0, + help='resume epoch id') + parser.add_argument( + '--data_dir', + type=str, + default='./data', + help='KITTI dataset root directory') + parser.add_argument( + '--gt_database', + type=str, + default='data/gt_database/train_gt_database_3level_Car.pkl', + help='generated gt database for augmentation') + parser.add_argument( + '--rcnn_training_roi_dir', + type=str, + default=None, + help='specify the saved rois for rcnn training when using rcnn_offline mode') + parser.add_argument( + '--rcnn_training_feature_dir', + type=str, + default=None, + help='specify the saved features for rcnn training when using rcnn_offline mode') + parser.add_argument( + '--log_interval', + type=int, + default=1, + help='mini-batch interval to log.') + parser.add_argument( + '--set', + dest='set_cfgs', + default=None, + nargs=argparse.REMAINDER, + help='set extra config keys if needed.') + args = parser.parse_args() + return args + + +def train(): + args = parse_args() + print_arguments(args) + # check whether the installed paddle is compiled with GPU + # PointRCNN model can only run on GPU + check_gpu(True) + + load_config(args.cfg) + if args.set_cfgs is not None: + set_config_from_list(args.set_cfgs) + + if args.train_mode == 'rpn': + cfg.RPN.ENABLED = True + cfg.RCNN.ENABLED = False + elif args.train_mode == 'rcnn': + cfg.RCNN.ENABLED = True + cfg.RPN.ENABLED = cfg.RPN.FIXED = True + elif args.train_mode == 'rcnn_offline': + cfg.RCNN.ENABLED = True + cfg.RPN.ENABLED = False + else: + raise NotImplementedError("unknown train mode: {}".format(args.train_mode)) + + checkpoints_dir = os.path.join(args.save_dir, args.train_mode) + if not os.path.isdir(checkpoints_dir): + os.makedirs(checkpoints_dir) + + kitti_rcnn_reader = KittiRCNNReader(data_dir=args.data_dir, + npoints=cfg.RPN.NUM_POINTS, + split=cfg.TRAIN.SPLIT, + mode='TRAIN', + classes=cfg.CLASSES, + rcnn_training_roi_dir=args.rcnn_training_roi_dir, + rcnn_training_feature_dir=args.rcnn_training_feature_dir, + gt_database_dir=args.gt_database) + num_samples = len(kitti_rcnn_reader) + steps_per_epoch = int(num_samples / args.batch_size) + logger.info("Total {} samples, {} batch per epoch.".format(num_samples, steps_per_epoch)) + boundaries = [i * steps_per_epoch for i in cfg.TRAIN.DECAY_STEP_LIST] + values = [cfg.TRAIN.LR * (cfg.TRAIN.LR_DECAY ** i) for i in range(len(boundaries) + 1)] + + place = fluid.CUDAPlace(0) + exe = fluid.Executor(place) + + # build model + startup = fluid.Program() + train_prog = fluid.Program() + with fluid.program_guard(train_prog, startup): + with fluid.unique_name.guard(): + train_model = PointRCNN(cfg, args.batch_size, True, 'TRAIN') + train_model.build() + train_pyreader = train_model.get_pyreader() + train_feeds = train_model.get_feeds() + train_outputs = train_model.get_outputs() + train_loss = train_outputs['loss'] + lr = optimize(train_loss, + learning_rate=cfg.TRAIN.LR, + warmup_factor=1. / cfg.TRAIN.DIV_FACTOR, + decay_factor=1e-5, + total_step=steps_per_epoch * args.epoch, + warmup_pct=cfg.TRAIN.PCT_START, + train_program=train_prog, + startup_prog=startup, + weight_decay=cfg.TRAIN.WEIGHT_DECAY, + clip_norm=cfg.TRAIN.GRAD_NORM_CLIP) + train_keys, train_values = parse_outputs(train_outputs, 'loss') + + exe.run(startup) + + if args.resume: + assert os.path.exists(args.resume), \ + "Given resume weight dir {} not exist.".format(args.resume) + def if_exist(var): + logger.debug("{}: {}".format(var.name, os.path.exists(os.path.join(args.resume, var.name)))) + return os.path.exists(os.path.join(args.resume, var.name)) + fluid.io.load_vars( + exe, args.resume, predicate=if_exist, main_program=train_prog) + + build_strategy = fluid.BuildStrategy() + build_strategy.memory_optimize = False + build_strategy.enable_inplace = False + build_strategy.fuse_all_optimizer_ops = False + train_compile_prog = fluid.compiler.CompiledProgram( + train_prog).with_data_parallel(loss_name=train_loss.name, + build_strategy=build_strategy) + + def save_model(exe, prog, path): + if os.path.isdir(path): + shutil.rmtree(path) + logger.info("Save model to {}".format(path)) + fluid.io.save_persistables(exe, path, prog) + + # get reader + train_reader = kitti_rcnn_reader.get_multiprocess_reader(args.batch_size, train_feeds, drop_last=True) + train_pyreader.decorate_sample_list_generator(train_reader, place) + + train_stat = Stat() + for epoch_id in range(args.resume_epoch, args.epoch): + try: + train_pyreader.start() + train_iter = 0 + train_periods = [] + while True: + cur_time = time.time() + train_outs = exe.run(train_compile_prog, fetch_list=train_values + [lr.name]) + period = time.time() - cur_time + train_periods.append(period) + train_stat.update(train_keys, train_outs[:-1]) + if train_iter % args.log_interval == 0: + log_str = "" + for name, values in zip(train_keys + ['learning_rate'], train_outs): + log_str += "{}: {:.6f}, ".format(name, np.mean(values)) + logger.info("[TRAIN] Epoch {}, batch {}: {}time: {:.2f}".format(epoch_id, train_iter, log_str, period)) + train_iter += 1 + except fluid.core.EOFException: + logger.info("[TRAIN] Epoch {} finished, {}average time: {:.2f}".format(epoch_id, train_stat.get_mean_log(), np.mean(train_periods[2:]))) + save_model(exe, train_prog, os.path.join(checkpoints_dir, str(epoch_id))) + train_stat.reset() + train_periods = [] + finally: + train_pyreader.reset() + + +if __name__ == "__main__": + train() diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/__init__.py b/PaddleCV/Paddle3D/PointRCNN/utils/__init__.py new file mode 100644 index 00000000..cad1d5d9 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +#Licensed under the Apache License, Version 2.0 (the "License"); +#you may not use this file except in compliance with the License. +#You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +#Unless required by applicable law or agreed to in writing, software +#distributed under the License is distributed on an "AS IS" BASIS, +#WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#See the License for the specific language governing permissions and +#limitations under the License. + diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/box_utils.py b/PaddleCV/Paddle3D/PointRCNN/utils/box_utils.py new file mode 100644 index 00000000..49c9ee74 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/box_utils.py @@ -0,0 +1,275 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +#Licensed under the Apache License, Version 2.0 (the "License"); +#you may not use this file except in compliance with the License. +#You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +#Unless required by applicable law or agreed to in writing, software +#distributed under the License is distributed on an "AS IS" BASIS, +#WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#See the License for the specific language governing permissions and +#limitations under the License. +""" +Contains proposal functions +""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import numpy as np +import paddle.fluid as fluid + +from utils.config import cfg + +__all__ = ["boxes3d_to_bev", "box_overlap_rotate", "boxes3d_to_bev", "box_iou", "box_nms"] + + +def boxes3d_to_bev(boxes3d): + """ + Args: + boxes3d: [N, 7], (x, y, z, h, w, l, ry) + Return: + boxes_bev: [N, 5], (x1, y1, x2, y2, ry) + """ + boxes_bev = np.zeros((boxes3d.shape[0], 5), dtype='float32') + + cu, cv = boxes3d[:, 0], boxes3d[:, 2] + half_l, half_w = boxes3d[:, 5] / 2, boxes3d[:, 4] / 2 + boxes_bev[:, 0], boxes_bev[:, 1] = cu - half_l, cv - half_w + boxes_bev[:, 2], boxes_bev[:, 3] = cu + half_l, cv + half_w + boxes_bev[:, 4] = boxes3d[:, 6] + return boxes_bev + + +def rotate_around_center(center, angle_cos, angle_sin, corners): + new_x = (corners[:, 0] - center[0]) * angle_cos + \ + (corners[:, 1] - center[1]) * angle_sin + center[0] + new_y = -(corners[:, 0] - center[0]) * angle_sin + \ + (corners[:, 1] - center[1]) * angle_cos + center[1] + return np.concatenate([new_x[:, np.newaxis], new_y[:, np.newaxis]], axis=-1) + + +def check_rect_cross(p1, p2, q1, q2): + return min(p1[0], p2[0]) <= max(q1[0], q2[0]) and \ + min(q1[0], q2[0]) <= max(p1[0], p2[0]) and \ + min(p1[1], p2[1]) <= max(q1[1], q2[1]) and \ + min(q1[1], q2[1]) <= max(p1[1], p2[1]) + + +def cross(p1, p2, p0): + return (p1[0] - p0[0]) * (p2[1] - p0[1]) - (p2[0] - p0[0]) * (p1[1] - p0[1]); + + +def cross_area(a, b): + return a[0] * b[1] - a[1] * b[0] + + +def intersection(p1, p0, q1, q0): + if not check_rect_cross(p1, p0, q1, q0): + return None + + s1 = cross(q0, p1, p0) + s2 = cross(p1, q1, p0) + s3 = cross(p0, q1, q0) + s4 = cross(q1, p1, q0) + if not (s1 * s2 > 0 and s3 * s4 > 0): + return None + + s5 = cross(q1, p1, p0) + if np.abs(s5 - s1) > 1e-8: + return np.array([(s5 * q0[0] - s1 * q1[0]) / (s5 - s1), + (s5 * q0[1] - s1 * q1[1]) / (s5 - s1)], dtype='float32') + else: + a0 = p0[1] - p1[1] + b0 = p1[0] - p0[0] + c0 = p0[0] * p1[1] - p1[0] * p0[1] + a0 = q0[1] - q1[1] + b0 = q1[0] - q0[0] + c0 = q0[0] * q1[1] - q1[0] * q0[1] + D = a0 * b1 - a1 * b0 + return np.array([(b0 * c1 - b1 * c0) / D, (a1 * c0 - a0 * c1) / D], dtype='float32') + + +def check_in_box2d(box, p): + center_x = (box[0] + box[2]) / 2. + center_y = (box[1] + box[3]) / 2. + angle_cos = np.cos(-box[4]) + angle_sin = np.sin(-box[4]) + rot_x = (p[0] - center_x) * angle_cos + (p[1] - center_y) * angle_sin + center_x + rot_y = -(p[0] - center_x) * angle_sin + (p[1] - center_y) * angle_cos + center_y + return rot_x > box[0] - 1e-5 and rot_x < box[2] + 1e-5 and \ + rot_y > box[1] - 1e-5 and rot_y < box[3] + 1e-5 + + +def point_cmp(a, b, center): + return np.arctan2(a[1] - center[1], a[0] - center[0]) > \ + np.arctan2(b[1] - center[1], b[0] - center[0]) + + +def box_overlap_rotate(cur_box, boxes): + """ + Calculate box overlap with rotate, box: [x1, y1, x2, y2, angle] + """ + areas = np.zeros((len(boxes), ), dtype='float32') + cur_center = [(cur_box[0] + cur_box[2]) / 2., (cur_box[1] + cur_box[3]) / 2.] + cur_corners = np.array([ + [cur_box[0], cur_box[1]], # (x1, y1) + [cur_box[2], cur_box[1]], # (x2, y1) + [cur_box[2], cur_box[3]], # (x2, y2) + [cur_box[0], cur_box[3]], # (x1, y2) + [cur_box[0], cur_box[1]], # (x1, y1) + ], dtype='float32') + cur_angle_cos = np.cos(cur_box[4]) + cur_angle_sin = np.sin(cur_box[4]) + cur_corners = rotate_around_center(cur_center, cur_angle_cos, cur_angle_sin, cur_corners) + + for i, box in enumerate(boxes): + box_center = [(box[0] + box[2]) / 2., (box[1] + box[3]) / 2.] + box_corners = np.array([ + [box[0], box[1]], + [box[2], box[1]], + [box[2], box[3]], + [box[0], box[3]], + [box[0], box[1]], + ], dtype='float32') + box_angle_cos = np.cos(box[4]) + box_angle_sin = np.sin(box[4]) + box_corners = rotate_around_center(box_center, box_angle_cos, box_angle_sin, box_corners) + + cross_points = np.zeros((16, 2), dtype='float32') + cnt = 0 + # get intersection of lines + for j in range(4): + for k in range(4): + inters = intersection(cur_corners[j + 1], cur_corners[j], + box_corners[k + 1], box_corners[k]) + if inters is not None: + cross_points[cnt, :] = inters + cnt += 1 + # check corners + for l in range(4): + if check_in_box2d(cur_box, box_corners[l]): + cross_points[cnt, :] = box_corners[l] + cnt += 1 + if check_in_box2d(box, cur_corners[l]): + cross_points[cnt, :] = cur_corners[l] + cnt += 1 + + if cnt > 0: + poly_center = np.sum(cross_points[:cnt, :], axis=0) / cnt + else: + poly_center = np.zeros((2,)) + + # sort the points of polygon + for j in range(cnt - 1): + for k in range(cnt - j - 1): + if point_cmp(cross_points[k], cross_points[k + 1], poly_center): + cross_points[k], cross_points[k + 1] = \ + cross_points[k + 1].copy(), cross_points[k].copy() + + # get the overlap areas + area = 0. + for j in range(cnt - 1): + area += cross_area(cross_points[j] - cross_points[0], + cross_points[j + 1] - cross_points[0]) + areas[i] = np.abs(area) / 2. + + return areas + + +def box_iou(cur_box, boxes, box_type='normal'): + cur_S = (cur_box[2] - cur_box[0]) * (cur_box[3] - cur_box[1]) + boxes_S = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1]) + + if box_type == 'normal': + inter_x1 = np.maximum(cur_box[0], boxes[:, 0]) + inter_y1 = np.maximum(cur_box[1], boxes[:, 1]) + inter_x2 = np.minimum(cur_box[2], boxes[:, 2]) + inter_y2 = np.minimum(cur_box[3], boxes[:, 3]) + inter_w = np.maximum(inter_x2 - inter_x1, 0.) + inter_h = np.maximum(inter_y2 - inter_y1, 0.) + inter_area = inter_w * inter_h + elif box_type == 'rotate': + inter_area = box_overlap_rotate(cur_box, boxes) + else: + raise NotImplementedError + + return inter_area / np.maximum(cur_S + boxes_S - inter_area, 1e-8) + + +def box_nms(boxes, scores, proposals, thresh, topk, nms_type='normal'): + assert nms_type in ['normal', 'rotate'], \ + "unknown nms type {}".format(nms_type) + order = np.argsort(-scores) + boxes = boxes[order] + scores = scores[order] + proposals = proposals[order] + + nmsed_scores = [] + nmsed_proposals = [] + cnt = 0 + while boxes.shape[0]: + nmsed_scores.append(scores[0]) + nmsed_proposals.append(proposals[0]) + cnt +=1 + if cnt >= topk or boxes.shape[0] == 1: + break + iou = box_iou(boxes[0], boxes[1:], nms_type) + boxes = boxes[1:][iou < thresh] + scores = scores[1:][iou < thresh] + proposals = proposals[1:][iou < thresh] + return nmsed_scores, nmsed_proposals + + +def box_nms_eval(boxes, scores, proposals, thresh, nms_type='rotate'): + assert nms_type in ['normal', 'rotate'], \ + "unknown nms type {}".format(nms_type) + order = np.argsort(-scores) + boxes = boxes[order] + scores = scores[order] + proposals = proposals[order] + + nmsed_scores = [] + nmsed_proposals = [] + while boxes.shape[0]: + nmsed_scores.append(scores[0]) + nmsed_proposals.append(proposals[0]) + iou = box_iou(boxes[0], boxes[1:], nms_type) + inds = iou < thresh + boxes = boxes[1:][inds] + scores = scores[1:][inds] + proposals = proposals[1:][inds] + nmsed_scores = np.asarray(nmsed_scores) + nmsed_proposals = np.asarray(nmsed_proposals) + return nmsed_scores, nmsed_proposals + +def boxes_iou3d(boxes1, boxes2): + boxes1_bev = boxes3d_to_bev(boxes1) + boxes2_bev = boxes3d_to_bev(boxes2) + + # bev overlap + overlaps_bev = np.zeros((boxes1_bev.shape[0], boxes2_bev.shape[0])) + for i in range(boxes1_bev.shape[0]): + overlaps_bev[i, :] = box_overlap_rotate(boxes1_bev[i], boxes2_bev) + + # height overlap + boxes1_height_min = (boxes1[:, 1] - boxes1[:, 3]).reshape(-1, 1) + boxes1_height_max = boxes1[:, 1].reshape(-1, 1) + boxes2_height_min = (boxes2[:, 1] - boxes2[:, 3]).reshape(1, -1) + boxes2_height_max = boxes2[:, 1].reshape(1, -1) + + max_of_min = np.maximum(boxes1_height_min, boxes2_height_min) + min_of_max = np.minimum(boxes1_height_max, boxes2_height_max) + overlaps_h = np.maximum(min_of_max - max_of_min, 0.) + + # 3d iou + overlaps_3d = overlaps_bev * overlaps_h + + vol_a = (boxes1[:, 3] * boxes1[:, 4] * boxes1[:, 5]).reshape(-1, 1) + vol_b = (boxes2[:, 3] * boxes2[:, 4] * boxes2[:, 5]).reshape(1, -1) + iou3d = overlaps_3d / np.maximum(vol_a + vol_b - overlaps_3d, 1e-7) + + return iou3d diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/calibration.py b/PaddleCV/Paddle3D/PointRCNN/utils/calibration.py new file mode 100644 index 00000000..41fcf279 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/calibration.py @@ -0,0 +1,143 @@ +""" +This code is borrow from https://github.com/sshaoshuai/PointRCNN/blob/master/lib/utils/kitti_utils.py +""" +import numpy as np +import os + + +def get_calib_from_file(calib_file): + with open(calib_file) as f: + lines = f.readlines() + + obj = lines[2].strip().split(' ')[1:] + P2 = np.array(obj, dtype=np.float32) + obj = lines[3].strip().split(' ')[1:] + P3 = np.array(obj, dtype=np.float32) + obj = lines[4].strip().split(' ')[1:] + R0 = np.array(obj, dtype=np.float32) + obj = lines[5].strip().split(' ')[1:] + Tr_velo_to_cam = np.array(obj, dtype=np.float32) + + return {'P2': P2.reshape(3, 4), + 'P3': P3.reshape(3, 4), + 'R0': R0.reshape(3, 3), + 'Tr_velo2cam': Tr_velo_to_cam.reshape(3, 4)} + + +class Calibration(object): + def __init__(self, calib_file): + if isinstance(calib_file, str): + calib = get_calib_from_file(calib_file) + else: + calib = calib_file + + self.P2 = calib['P2'] # 3 x 4 + self.R0 = calib['R0'] # 3 x 3 + self.V2C = calib['Tr_velo2cam'] # 3 x 4 + + # Camera intrinsics and extrinsics + self.cu = self.P2[0, 2] + self.cv = self.P2[1, 2] + self.fu = self.P2[0, 0] + self.fv = self.P2[1, 1] + self.tx = self.P2[0, 3] / (-self.fu) + self.ty = self.P2[1, 3] / (-self.fv) + + def cart_to_hom(self, pts): + """ + :param pts: (N, 3 or 2) + :return pts_hom: (N, 4 or 3) + """ + pts_hom = np.hstack((pts, np.ones((pts.shape[0], 1), dtype=np.float32))) + return pts_hom + + def lidar_to_rect(self, pts_lidar): + """ + :param pts_lidar: (N, 3) + :return pts_rect: (N, 3) + """ + pts_lidar_hom = self.cart_to_hom(pts_lidar) + pts_rect = np.dot(pts_lidar_hom, np.dot(self.V2C.T, self.R0.T)) + # pts_rect = reduce(np.dot, (pts_lidar_hom, self.V2C.T, self.R0.T)) + return pts_rect + + def rect_to_img(self, pts_rect): + """ + :param pts_rect: (N, 3) + :return pts_img: (N, 2) + """ + pts_rect_hom = self.cart_to_hom(pts_rect) + pts_2d_hom = np.dot(pts_rect_hom, self.P2.T) + pts_img = (pts_2d_hom[:, 0:2].T / pts_rect_hom[:, 2]).T # (N, 2) + pts_rect_depth = pts_2d_hom[:, 2] - self.P2.T[3, 2] # depth in rect camera coord + return pts_img, pts_rect_depth + + def lidar_to_img(self, pts_lidar): + """ + :param pts_lidar: (N, 3) + :return pts_img: (N, 2) + """ + pts_rect = self.lidar_to_rect(pts_lidar) + pts_img, pts_depth = self.rect_to_img(pts_rect) + return pts_img, pts_depth + + def img_to_rect(self, u, v, depth_rect): + """ + :param u: (N) + :param v: (N) + :param depth_rect: (N) + :return: + """ + x = ((u - self.cu) * depth_rect) / self.fu + self.tx + y = ((v - self.cv) * depth_rect) / self.fv + self.ty + pts_rect = np.concatenate((x.reshape(-1, 1), y.reshape(-1, 1), depth_rect.reshape(-1, 1)), axis=1) + return pts_rect + + def depthmap_to_rect(self, depth_map): + """ + :param depth_map: (H, W), depth_map + :return: + """ + x_range = np.arange(0, depth_map.shape[1]) + y_range = np.arange(0, depth_map.shape[0]) + x_idxs, y_idxs = np.meshgrid(x_range, y_range) + x_idxs, y_idxs = x_idxs.reshape(-1), y_idxs.reshape(-1) + depth = depth_map[y_idxs, x_idxs] + pts_rect = self.img_to_rect(x_idxs, y_idxs, depth) + return pts_rect, x_idxs, y_idxs + + def corners3d_to_img_boxes(self, corners3d): + """ + :param corners3d: (N, 8, 3) corners in rect coordinate + :return: boxes: (None, 4) [x1, y1, x2, y2] in rgb coordinate + :return: boxes_corner: (None, 8) [xi, yi] in rgb coordinate + """ + sample_num = corners3d.shape[0] + corners3d_hom = np.concatenate((corners3d, np.ones((sample_num, 8, 1))), axis=2) # (N, 8, 4) + + img_pts = np.matmul(corners3d_hom, self.P2.T) # (N, 8, 3) + + x, y = img_pts[:, :, 0] / img_pts[:, :, 2], img_pts[:, :, 1] / img_pts[:, :, 2] + x1, y1 = np.min(x, axis=1), np.min(y, axis=1) + x2, y2 = np.max(x, axis=1), np.max(y, axis=1) + + boxes = np.concatenate((x1.reshape(-1, 1), y1.reshape(-1, 1), x2.reshape(-1, 1), y2.reshape(-1, 1)), axis=1) + boxes_corner = np.concatenate((x.reshape(-1, 8, 1), y.reshape(-1, 8, 1)), axis=2) + + return boxes, boxes_corner + + def camera_dis_to_rect(self, u, v, d): + """ + Can only process valid u, v, d, which means u, v can not beyond the image shape, reprojection error 0.02 + :param u: (N) + :param v: (N) + :param d: (N), the distance between camera and 3d points, d^2 = x^2 + y^2 + z^2 + :return: + """ + assert self.fu == self.fv, '%.8f != %.8f' % (self.fu, self.fv) + fd = np.sqrt((u - self.cu)**2 + (v - self.cv)**2 + self.fu**2) + x = ((u - self.cu) * d) / fd + self.tx + y = ((v - self.cv) * d) / fd + self.ty + z = np.sqrt(d**2 - x**2 - y**2) + pts_rect = np.concatenate((x.reshape(-1, 1), y.reshape(-1, 1), z.reshape(-1, 1)), axis=1) + return pts_rect diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/config.py b/PaddleCV/Paddle3D/PointRCNN/utils/config.py new file mode 100644 index 00000000..dc24aee5 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/config.py @@ -0,0 +1,279 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +#Licensed under the Apache License, Version 2.0 (the "License"); +#you may not use this file except in compliance with the License. +#You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +#Unless required by applicable law or agreed to in writing, software +#distributed under the License is distributed on an "AS IS" BASIS, +#WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#See the License for the specific language governing permissions and +#limitations under the License. +""" +This code is bases on https://github.com/sshaoshuai/PointRCNN/blob/master/lib/config.py +""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import yaml +import numpy as np +from ast import literal_eval + +__all__ = ["load_config", "cfg"] + + +class AttrDict(dict): + def __init__(self, *args, **kwargs): + for arg in args: + for k, v in arg.items(): + if isinstance(v, dict): + arg[k] = AttrDict(v) + else: + arg[k] = v + super(AttrDict, self).__init__(*args, **kwargs) + + def __getattr__(self, name): + if name in self.__dict__: + return self.__dict__[name] + elif name in self: + return self[name] + else: + raise AttributeError(name) + + def __setattr__(self, name, value): + if name in self.__dict__: + self.__dict__[name] = value + else: + self[name] = value + + +__C = AttrDict() +cfg = __C + +# 0. basic config +__C.TAG = 'default' +__C.CLASSES = 'Car' + +__C.INCLUDE_SIMILAR_TYPE = False + +# config of augmentation +__C.AUG_DATA = True +__C.AUG_METHOD_LIST = ['rotation', 'scaling', 'flip'] +__C.AUG_METHOD_PROB = [0.5, 0.5, 0.5] +__C.AUG_ROT_RANGE = 18 + +__C.GT_AUG_ENABLED = False +__C.GT_EXTRA_NUM = 15 +__C.GT_AUG_RAND_NUM = False +__C.GT_AUG_APPLY_PROB = 0.75 +__C.GT_AUG_HARD_RATIO = 0.6 + +__C.PC_REDUCE_BY_RANGE = True +__C.PC_AREA_SCOPE = np.array([[-40, 40], + [-1, 3], + [0, 70.4]]) # x, y, z scope in rect camera coords + +__C.CLS_MEAN_SIZE = np.array([[1.52, 1.63, 3.88]], dtype=np.float32) + + +# 1. config of rpn network +__C.RPN = AttrDict() +__C.RPN.ENABLED = True +__C.RPN.FIXED = False + +__C.RPN.USE_INTENSITY = True + +# config of bin-based loss +__C.RPN.LOC_XZ_FINE = False +__C.RPN.LOC_SCOPE = 3.0 +__C.RPN.LOC_BIN_SIZE = 0.5 +__C.RPN.NUM_HEAD_BIN = 12 + +# config of network structure +__C.RPN.BACKBONE = 'pointnet2_msg' + +__C.RPN.USE_BN = True +__C.RPN.NUM_POINTS = 16384 + +__C.RPN.SA_CONFIG = AttrDict() +__C.RPN.SA_CONFIG.NPOINTS = [4096, 1024, 256, 64] +__C.RPN.SA_CONFIG.RADIUS = [[0.1, 0.5], [0.5, 1.0], [1.0, 2.0], [2.0, 4.0]] +__C.RPN.SA_CONFIG.NSAMPLE = [[16, 32], [16, 32], [16, 32], [16, 32]] +__C.RPN.SA_CONFIG.MLPS = [[[16, 16, 32], [32, 32, 64]], + [[64, 64, 128], [64, 96, 128]], + [[128, 196, 256], [128, 196, 256]], + [[256, 256, 512], [256, 384, 512]]] +__C.RPN.FP_MLPS = [[128, 128], [256, 256], [512, 512], [512, 512]] +__C.RPN.CLS_FC = [128] +__C.RPN.REG_FC = [128] +__C.RPN.DP_RATIO = 0.5 + +# config of training +__C.RPN.LOSS_CLS = 'DiceLoss' +__C.RPN.FG_WEIGHT = 15 +__C.RPN.FOCAL_ALPHA = [0.25, 0.75] +__C.RPN.FOCAL_GAMMA = 2.0 +__C.RPN.REG_LOSS_WEIGHT = [1.0, 1.0, 1.0, 1.0] +__C.RPN.LOSS_WEIGHT = [1.0, 1.0] +__C.RPN.NMS_TYPE = 'normal' # normal, rotate + +# config of testing +__C.RPN.SCORE_THRESH = 0.3 + + +# 2. config of rcnn network +__C.RCNN = AttrDict() +__C.RCNN.ENABLED = False + +# config of input +__C.RCNN.USE_RPN_FEATURES = True +__C.RCNN.USE_MASK = True +__C.RCNN.MASK_TYPE = 'seg' +__C.RCNN.USE_INTENSITY = False +__C.RCNN.USE_DEPTH = True +__C.RCNN.USE_SEG_SCORE = False +__C.RCNN.ROI_SAMPLE_JIT = False +__C.RCNN.ROI_FG_AUG_TIMES = 10 + +__C.RCNN.REG_AUG_METHOD = 'multiple' # multiple, single, normal +__C.RCNN.POOL_EXTRA_WIDTH = 1.0 + +# config of bin-based loss +__C.RCNN.LOC_SCOPE = 1.5 +__C.RCNN.LOC_BIN_SIZE = 0.5 +__C.RCNN.NUM_HEAD_BIN = 9 +__C.RCNN.LOC_Y_BY_BIN = False +__C.RCNN.LOC_Y_SCOPE = 0.5 +__C.RCNN.LOC_Y_BIN_SIZE = 0.25 +__C.RCNN.SIZE_RES_ON_ROI = False + +# config of network structure +__C.RCNN.USE_BN = False +__C.RCNN.DP_RATIO = 0.0 + +__C.RCNN.BACKBONE = 'pointnet' # pointnet, pointsift +__C.RCNN.XYZ_UP_LAYER = [128, 128] + +__C.RCNN.NUM_POINTS = 512 +__C.RCNN.SA_CONFIG = AttrDict() +__C.RCNN.SA_CONFIG.NPOINTS = [128, 32, -1] +__C.RCNN.SA_CONFIG.RADIUS = [0.2, 0.4, 100] +__C.RCNN.SA_CONFIG.NSAMPLE = [64, 64, 64] +__C.RCNN.SA_CONFIG.MLPS = [[128, 128, 128], + [128, 128, 256], + [256, 256, 512]] +__C.RCNN.CLS_FC = [256, 256] +__C.RCNN.REG_FC = [256, 256] + +# config of training +__C.RCNN.LOSS_CLS = 'BinaryCrossEntropy' +__C.RCNN.FOCAL_ALPHA = [0.25, 0.75] +__C.RCNN.FOCAL_GAMMA = 2.0 +__C.RCNN.CLS_WEIGHT = np.array([1.0, 1.0, 1.0], dtype=np.float32) +__C.RCNN.CLS_FG_THRESH = 0.6 +__C.RCNN.CLS_BG_THRESH = 0.45 +__C.RCNN.CLS_BG_THRESH_LO = 0.05 +__C.RCNN.REG_FG_THRESH = 0.55 +__C.RCNN.FG_RATIO = 0.5 +__C.RCNN.ROI_PER_IMAGE = 64 +__C.RCNN.HARD_BG_RATIO = 0.6 + +# config of testing +__C.RCNN.SCORE_THRESH = 0.3 +__C.RCNN.NMS_THRESH = 0.1 + + +# general training config +__C.TRAIN = AttrDict() +__C.TRAIN.SPLIT = 'train' +__C.TRAIN.VAL_SPLIT = 'smallval' + +__C.TRAIN.LR = 0.002 +__C.TRAIN.LR_CLIP = 0.00001 +__C.TRAIN.LR_DECAY = 0.5 +__C.TRAIN.DECAY_STEP_LIST = [50, 100, 150, 200, 250, 300] +__C.TRAIN.LR_WARMUP = False +__C.TRAIN.WARMUP_MIN = 0.0002 +__C.TRAIN.WARMUP_EPOCH = 5 + +__C.TRAIN.BN_MOMENTUM = 0.9 +__C.TRAIN.BN_DECAY = 0.5 +__C.TRAIN.BNM_CLIP = 0.01 +__C.TRAIN.BN_DECAY_STEP_LIST = [50, 100, 150, 200, 250, 300] + +__C.TRAIN.OPTIMIZER = 'adam' +__C.TRAIN.WEIGHT_DECAY = 0.0 # "L2 regularization coeff [default: 0.0]" +__C.TRAIN.MOMENTUM = 0.9 + +__C.TRAIN.MOMS = [0.95, 0.85] +__C.TRAIN.DIV_FACTOR = 10.0 +__C.TRAIN.PCT_START = 0.4 + +__C.TRAIN.GRAD_NORM_CLIP = 1.0 + +__C.TRAIN.RPN_PRE_NMS_TOP_N = 12000 +__C.TRAIN.RPN_POST_NMS_TOP_N = 2048 +__C.TRAIN.RPN_NMS_THRESH = 0.85 +__C.TRAIN.RPN_DISTANCE_BASED_PROPOSE = True + + +__C.TEST = AttrDict() +__C.TEST.SPLIT = 'val' +__C.TEST.RPN_PRE_NMS_TOP_N = 9000 +__C.TEST.RPN_POST_NMS_TOP_N = 300 +__C.TEST.RPN_NMS_THRESH = 0.7 +__C.TEST.RPN_DISTANCE_BASED_PROPOSE = True + + +def load_config(fname): + """ + Load config from yaml file and merge into global cfg + """ + with open(fname) as f: + yml_cfg = AttrDict(yaml.load(f.read(), Loader=yaml.Loader)) + _merge_cfg_a_to_b(yml_cfg, __C) + + +def set_config_from_list(cfg_list): + assert len(cfg_list) % 2 == 0, "cfgs list length invalid" + for k, v in zip(cfg_list[0::2], cfg_list[1::2]): + key_list = k.split('.') + d = __C + for subkey in key_list[:-1]: + assert subkey in d + d = d[subkey] + subkey = key_list[-1] + assert subkey in d + try: + value = literal_eval(v) + except: + # handle the case when v is a string literal + value = v + assert type(value) == type(d[subkey]), \ + 'type {} does not match original type {}'.format(type(value), type(d[subkey])) + d[subkey] = value + + +def _merge_cfg_a_to_b(a, b): + assert isinstance(a, AttrDict), \ + "unknown type {}".format(type(a)) + + for k, v in a.items(): + assert k in b, "unknown key {}".format(k) + if type(v) is not type(b[k]): + if isinstance(b[k], np.ndarray): + b[k] = np.array(v, dtype=b[k].dtype) + else: + raise TypeError("Config type mismatch") + if isinstance(v, AttrDict): + _merge_cfg_a_to_b(v, b[k]) + else: + b[k] = v + + +if __name__ == "__main__": + load_config("./cfgs/default.yml") diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/cyops/__init__.py b/PaddleCV/Paddle3D/PointRCNN/utils/cyops/__init__.py new file mode 100644 index 00000000..e02c5492 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/cyops/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +#Licensed under the Apache License, Version 2.0 (the "License"); +#you may not use this file except in compliance with the License. +#You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +#Unless required by applicable law or agreed to in writing, software +#distributed under the License is distributed on an "AS IS" BASIS, +#WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#See the License for the specific language governing permissions and +#limitations under the License. + + diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/cyops/iou3d_utils.pyx b/PaddleCV/Paddle3D/PointRCNN/utils/cyops/iou3d_utils.pyx new file mode 100644 index 00000000..b2c7f3c7 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/cyops/iou3d_utils.pyx @@ -0,0 +1,195 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +#Licensed under the Apache License, Version 2.0 (the "License"); +#you may not use this file except in compliance with the License. +#You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +#Unless required by applicable law or agreed to in writing, software +#distributed under the License is distributed on an "AS IS" BASIS, +#WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#See the License for the specific language governing permissions and +#limitations under the License. + +import cython +from math import pi, cos, sin +import numpy as np +cimport numpy as np + + +cdef class Point: + cdef float x, y + def __cinit__(self, x, y): + self.x = x + self.y = y + + def __add__(self, v): + if not isinstance(v, Point): + return NotImplemented + return Point(self.x + v.x, self.y + v.y) + + def __sub__(self, v): + if not isinstance(v, Point): + return NotImplemented + return Point(self.x - v.x, self.y - v.y) + + def cross(self, v): + if not isinstance(v, Point): + return NotImplemented + return self.x*v.y - self.y*v.x + + +cdef class Line: + cdef float a, b, c + # ax + by + c = 0 + def __cinit__(self, v1, v2): + self.a = v2.y - v1.y + self.b = v1.x - v2.x + self.c = v2.cross(v1) + + def __call__(self, p): + return self.a*p.x + self.b*p.y + self.c + + def intersection(self, other): + if not isinstance(other, Line): + return NotImplemented + w = self.a*other.b - self.b*other.a + return Point( + (self.b*other.c - self.c*other.b)/w, + (self.c*other.a - self.a*other.c)/w + ) + + +@cython.boundscheck(False) +@cython.wraparound(False) +def rectangle_vertices_(x1, y1, x2, y2, r): + + cx = (x1 + x2) / 2 + cy = (y1 + y2) / 2 + angle = r + cr = cos(angle) + sr = sin(angle) + # rotate around center + return ( + Point( + x=(x1-cx)*cr+(y1-cy)*sr+cx, + y=-(x1-cx)*sr+(y1-cy)*cr+cy + ), + Point( + x=(x2-cx)*cr+(y1-cy)*sr+cx, + y=-(x2-cx)*sr+(y1-cy)*cr+cy + ), + Point( + x=(x2-cx)*cr+(y2-cy)*sr+cx, + y=-(x2-cx)*sr+(y2-cy)*cr+cy + ), + Point( + x=(x1-cx)*cr+(y2-cy)*sr+cx, + y=-(x1-cx)*sr+(y2-cy)*cr+cy + ) + ) + +@cython.boundscheck(False) +@cython.wraparound(False) +def intersection_area(r1, r2): + # r1 and r2 are in (center, width, height, rotation) representation + # First convert these into a sequence of vertices + + rect1 = rectangle_vertices_(*r1) + rect2 = rectangle_vertices_(*r2) + + # Use the vertices of the first rectangle as + # starting vertices of the intersection polygon. + intersection = rect1 + + # Loop over the edges of the second rectangle + for p, q in zip(rect2, rect2[1:] + rect2[:1]): + if len(intersection) <= 2: + break # No intersection + + line = Line(p, q) + + # Any point p with line(p) <= 0 is on the "inside" (or on the boundary), + # any point p with line(p) > 0 is on the "outside". + + # Loop over the edges of the intersection polygon, + # and determine which part is inside and which is outside. + new_intersection = [] + line_values = [line(t) for t in intersection] + for s, t, s_value, t_value in zip( + intersection, intersection[1:] + intersection[:1], + line_values, line_values[1:] + line_values[:1]): + if s_value <= 0: + new_intersection.append(s) + if s_value * t_value < 0: + # Points are on opposite sides. + # Add the intersection of the lines to new_intersection. + intersection_point = line.intersection(Line(s, t)) + new_intersection.append(intersection_point) + + intersection = new_intersection + + # Calculate area + if len(intersection) <= 2: + return 0 + + return 0.5 * sum(p.x*q.y - p.y*q.x for p, q in zip(intersection, intersection[1:] + intersection[:1])) + + +def boxes3d_to_bev_(boxes3d): + """ + Args: + boxes3d: [N, 7], (x, y, z, h, w, l, ry) + Return: + boxes_bev: [N, 5], (x1, y1, x2, y2, ry) + """ + boxes_bev = np.zeros((boxes3d.shape[0], 5), dtype='float32') + cu, cv = boxes3d[:, 0], boxes3d[:, 2] + half_l, half_w = boxes3d[:, 5] / 2, boxes3d[:, 4] / 2 + boxes_bev[:, 0], boxes_bev[:, 1] = cu - half_l, cv - half_w + boxes_bev[:, 2], boxes_bev[:, 3] = cu + half_l, cv + half_w + boxes_bev[:, 4] = boxes3d[:, 6] + return boxes_bev + + +def boxes_iou3d(boxes_a, boxes_b): + """ + :param boxes_a: (N, 7) [x, y, z, h, w, l, ry] + :param boxes_b: (M, 7) [x, y, z, h, w, l, ry] + :return: + ans_iou: (M, N) + """ + boxes_a_bev = boxes3d_to_bev_(boxes_a) + boxes_b_bev = boxes3d_to_bev_(boxes_b) + # bev overlap + num_a = boxes_a_bev.shape[0] + num_b = boxes_b_bev.shape[0] + overlaps_bev = np.zeros((num_a, num_b), dtype=np.float32) + for i in range(num_a): + for j in range(num_b): + overlaps_bev[i][j] = intersection_area(boxes_a_bev[i], boxes_b_bev[j]) + + # height overlap + boxes_a_height_min = (boxes_a[:, 1] - boxes_a[:, 3]).reshape(-1, 1) + boxes_a_height_max = boxes_a[:, 1].reshape(-1, 1) + boxes_b_height_min = (boxes_b[:, 1] - boxes_b[:, 3]).reshape(1, -1) + boxes_b_height_max = boxes_b[:, 1].reshape(1, -1) + + max_of_min = np.maximum(boxes_a_height_min, boxes_b_height_min) + min_of_max = np.minimum(boxes_a_height_max, boxes_b_height_max) + overlaps_h = np.clip(min_of_max - max_of_min, a_min=0, a_max=np.inf) + # 3d iou + overlaps_3d = overlaps_bev * overlaps_h + + vol_a = (boxes_a[:, 3] * boxes_a[:, 4] * boxes_a[:, 5]).reshape(-1, 1) + vol_b = (boxes_b[:, 3] * boxes_b[:, 4] * boxes_b[:, 5]).reshape(1, -1) + + iou3d = overlaps_3d / np.clip(vol_a + vol_b - overlaps_3d, a_min=1e-7, a_max=np.inf) + return iou3d + +#if __name__ == '__main__': +# # (center, width, height, rotation) +# r1 = (10, 15, 15, 10, 30) +# r2 = (15, 15, 20, 10, 0) +# print(intersection_area(r1, r2)) diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/cyops/kitti_utils.pyx b/PaddleCV/Paddle3D/PointRCNN/utils/cyops/kitti_utils.pyx new file mode 100644 index 00000000..593dd0c9 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/cyops/kitti_utils.pyx @@ -0,0 +1,346 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +#Licensed under the Apache License, Version 2.0 (the "License"); +#you may not use this file except in compliance with the License. +#You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +#Unless required by applicable law or agreed to in writing, software +#distributed under the License is distributed on an "AS IS" BASIS, +#WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#See the License for the specific language governing permissions and +#limitations under the License. + +import cython +import numpy as np +cimport numpy as np + +@cython.boundscheck(False) +@cython.wraparound(False) +def pts_in_boxes3d(np.ndarray pts_rect, np.ndarray boxes3d): + """ + :param pts: (N, 3) in rect-camera coords + :param boxes3d: (M, 7) + :return: boxes_pts_mask_list: (M), list with [(N), (N), ..] + """ + cdef float MAX_DIS = 10.0 + cdef np.ndarray boxes_pts_mask_list = np.zeros((boxes3d.shape[0], pts_rect.shape[0]), dtype='int32') + cdef int boxes3d_num = boxes3d.shape[0] + cdef int pts_rect_num = pts_rect.shape[0] + cdef float cx, by, cz, h, w, l, angle, cy, cosa, sina, x_rot, z_rot + cdef int x, y, z + + for i in range(boxes3d_num): + cx, by, cz, h, w, l, angle = boxes3d[i, :] + cy = by - h / 2. + cosa = np.cos(angle) + sina = np.sin(angle) + for j in range(pts_rect_num): + x, y, z = pts_rect[j, :] + + if np.abs(x - cx) > MAX_DIS or np.abs(y - cy) > h / 2. or np.abs(z - cz) > MAX_DIS: + continue + + x_rot = (x - cx) * cosa + (z - cz) * (-sina) + z_rot = (x - cx) * sina + (z - cz) * cosa + boxes_pts_mask_list[i, j] = int(x_rot >= -l / 2. and x_rot <= l / 2. and + z_rot >= -w / 2. and z_rot <= w / 2.) + return boxes_pts_mask_list + + +@cython.boundscheck(False) +@cython.wraparound(False) +def rotate_pc_along_y(np.ndarray pc, float rot_angle): + """ + params pc: (N, 3+C), (N, 3) is in the rectified camera coordinate + params rot_angle: rad scalar + Output pc: updated pc with XYZ rotated + """ + cosval = np.cos(rot_angle) + sinval = np.sin(rot_angle) + rotmat = np.array([[cosval, -sinval], [sinval, cosval]]) + pc[:, [0, 2]] = np.dot(pc[:, [0, 2]], np.transpose(rotmat)) + return pc + + +@cython.boundscheck(False) +@cython.wraparound(False) +def rotate_pc_along_y_np(np.ndarray pc, np.ndarray rot_angle): + """ + :param pc: (N, 512, 3 + C) + :param rot_angle: (N) + :return: + TODO: merge with rotate_pc_along_y_torch in bbox_transform.py + """ + cdef np.ndarray cosa, sina, raw_1, raw_2, R, pc_temp + cosa = np.cos(rot_angle).reshape(-1, 1) + sina = np.sin(rot_angle).reshape(-1, 1) + raw_1 = np.concatenate([cosa, -sina], axis=1) + raw_2 = np.concatenate([sina, cosa], axis=1) + # # (N, 2, 2) + R = np.concatenate((np.expand_dims(raw_1, axis=1), np.expand_dims(raw_2, axis=1)), axis=1) + pc_temp = pc[:, :, [0, 2]] + pc[:, :, [0, 2]] = np.matmul(pc_temp, R.transpose(0, 2, 1)) + + return pc + + +@cython.boundscheck(False) +@cython.wraparound(False) +def enlarge_box3d(np.ndarray boxes3d, float extra_width): + """ + :param boxes3d: (N, 7) [x, y, z, h, w, l, ry] + """ + cdef np.ndarray large_boxes3d + if isinstance(boxes3d, np.ndarray): + large_boxes3d = boxes3d.copy() + else: + large_boxes3d = boxes3d.clone() + large_boxes3d[:, 3:6] += extra_width * 2 + large_boxes3d[:, 1] += extra_width + + return large_boxes3d + + +@cython.boundscheck(False) +@cython.wraparound(False) +def boxes3d_to_corners3d(np.ndarray boxes3d, bint rotate=True): + """ + :param boxes3d: (N, 7) [x, y, z, h, w, l, ry] + :param rotate: + :return: corners3d: (N, 8, 3) + """ + cdef int boxes_num = boxes3d.shape[0] + cdef np.ndarray h, w, l + h, w, l = boxes3d[:, 3], boxes3d[:, 4], boxes3d[:, 5] + cdef np.ndarray x_corners, y_corners + x_corners = np.array([l / 2., l / 2., -l / 2., -l / 2., l / 2., l / 2., -l / 2., -l / 2.], dtype=np.float32).T # (N, 8) + z_corners = np.array([w / 2., -w / 2., -w / 2., w / 2., w / 2., -w / 2., -w / 2., w / 2.], dtype=np.float32).T # (N, 8) + + y_corners = np.zeros((boxes_num, 8), dtype=np.float32) + y_corners[:, 4:8] = -h.reshape(boxes_num, 1).repeat(4, axis=1) # (N, 8) + + cdef np.ndarray ry, zeros, ones, rot_list, R_list, temp_corners, rotated_corners + if rotate: + ry = boxes3d[:, 6] + zeros, ones = np.zeros(ry.size, dtype=np.float32), np.ones(ry.size, dtype=np.float32) + rot_list = np.array([[np.cos(ry), zeros, -np.sin(ry)], + [zeros, ones, zeros], + [np.sin(ry), zeros, np.cos(ry)]]) # (3, 3, N) + R_list = np.transpose(rot_list, (2, 0, 1)) # (N, 3, 3) + + temp_corners = np.concatenate((x_corners.reshape(-1, 8, 1), y_corners.reshape(-1, 8, 1), + z_corners.reshape(-1, 8, 1)), axis=2) # (N, 8, 3) + rotated_corners = np.matmul(temp_corners, R_list) # (N, 8, 3) + x_corners, y_corners, z_corners = rotated_corners[:, :, 0], rotated_corners[:, :, 1], rotated_corners[:, :, 2] + + cdef np.ndarray x_loc, y_loc, z_loc + x_loc, y_loc, z_loc = boxes3d[:, 0], boxes3d[:, 1], boxes3d[:, 2] + + cdef np.ndarray x, y, z, corners + x = x_loc.reshape(-1, 1) + x_corners.reshape(-1, 8) + y = y_loc.reshape(-1, 1) + y_corners.reshape(-1, 8) + z = z_loc.reshape(-1, 1) + z_corners.reshape(-1, 8) + + corners = np.concatenate((x.reshape(-1, 8, 1), y.reshape(-1, 8, 1), z.reshape(-1, 8, 1)), axis=2).astype(np.float32) + + return corners + + +@cython.boundscheck(False) +@cython.wraparound(False) +def objs_to_boxes3d(obj_list): + cdef np.ndarray boxes3d = np.zeros((obj_list.__len__(), 7), dtype=np.float32) + cdef int k + for k, obj in enumerate(obj_list): + boxes3d[k, 0:3], boxes3d[k, 3], boxes3d[k, 4], boxes3d[k, 5], boxes3d[k, 6] \ + = obj.pos, obj.h, obj.w, obj.l, obj.ry + return boxes3d + + +@cython.boundscheck(False) +@cython.wraparound(False) +def objs_to_scores(obj_list): + cdef np.ndarray scores = np.zeros((obj_list.__len__()), dtype=np.float32) + cdef int k + for k, obj in enumerate(obj_list): + scores[k] = obj.score + return scores + + +def get_iou3d(np.ndarray corners3d, np.ndarray query_corners3d, bint need_bev=False): + """ + :param corners3d: (N, 8, 3) in rect coords + :param query_corners3d: (M, 8, 3) + :return: + """ + from shapely.geometry import Polygon + A, B = corners3d, query_corners3d + N, M = A.shape[0], B.shape[0] + iou3d = np.zeros((N, M), dtype=np.float32) + iou_bev = np.zeros((N, M), dtype=np.float32) + + # for height overlap, since y face down, use the negative y + min_h_a = -A[:, 0:4, 1].sum(axis=1) / 4.0 + max_h_a = -A[:, 4:8, 1].sum(axis=1) / 4.0 + min_h_b = -B[:, 0:4, 1].sum(axis=1) / 4.0 + max_h_b = -B[:, 4:8, 1].sum(axis=1) / 4.0 + + for i in range(N): + for j in range(M): + max_of_min = np.max([min_h_a[i], min_h_b[j]]) + min_of_max = np.min([max_h_a[i], max_h_b[j]]) + h_overlap = np.max([0, min_of_max - max_of_min]) + if h_overlap == 0: + continue + + bottom_a, bottom_b = Polygon(A[i, 0:4, [0, 2]].T), Polygon(B[j, 0:4, [0, 2]].T) + if bottom_a.is_valid and bottom_b.is_valid: + # check is valid, A valid Polygon may not possess any overlapping exterior or interior rings. + bottom_overlap = bottom_a.intersection(bottom_b).area + else: + bottom_overlap = 0. + overlap3d = bottom_overlap * h_overlap + union3d = bottom_a.area * (max_h_a[i] - min_h_a[i]) + bottom_b.area * (max_h_b[j] - min_h_b[j]) - overlap3d + iou3d[i][j] = overlap3d / union3d + iou_bev[i][j] = bottom_overlap / (bottom_a.area + bottom_b.area - bottom_overlap) + + if need_bev: + return iou3d, iou_bev + + return iou3d + + +def get_objects_from_label(label_file): + import utils.object3d as object3d + + with open(label_file, 'r') as f: + lines = f.readlines() + objects = [object3d.Object3d(line) for line in lines] + return objects + + +@cython.boundscheck(False) +@cython.wraparound(False) +def _rotate_pc_along_y(np.ndarray pc, np.ndarray angle): + cdef np.ndarray cosa = np.cos(angle) + cosa=cosa.reshape(-1, 1) + cdef np.ndarray sina = np.sin(angle) + sina = sina.reshape(-1, 1) + + cdef np.ndarray R = np.concatenate([cosa, -sina, sina, cosa], axis=-1) + R = R.reshape(-1, 2, 2) + cdef np.ndarray pc_temp = pc[:, [0, 2]] + pc_temp = pc_temp.reshape(-1, 1, 2) + cdef np.ndarray pc_temp_1 = np.matmul(pc_temp, R.transpose(0, 2, 1)) + pc_temp_1 = pc_temp_1.reshape(-1, 2) + pc[:,[0,2]] = pc_temp_1 + + return pc + +@cython.boundscheck(False) +@cython.wraparound(False) +def decode_bbox_target( + np.ndarray roi_box3d, + np.ndarray pred_reg, + np.ndarray anchor_size, + float loc_scope, + float loc_bin_size, + int num_head_bin, + bint get_xz_fine=True, + float loc_y_scope=0.5, + float loc_y_bin_size=0.25, + bint get_y_by_bin=False, + bint get_ry_fine=False): + + cdef int per_loc_bin_num = int(loc_scope / loc_bin_size) * 2 + cdef int loc_y_bin_num = int(loc_y_scope / loc_y_bin_size) * 2 + + # recover xz localization + cdef int x_bin_l = 0 + cdef int x_bin_r = per_loc_bin_num + cdef int z_bin_l = per_loc_bin_num, + cdef int z_bin_r = per_loc_bin_num * 2 + cdef int start_offset = z_bin_r + cdef np.ndarray x_bin = np.argmax(pred_reg[:, x_bin_l: x_bin_r], axis=1) + cdef np.ndarray z_bin = np.argmax(pred_reg[:, z_bin_l: z_bin_r], axis=1) + + cdef np.ndarray pos_x = x_bin.astype('float32') * loc_bin_size + loc_bin_size / 2 - loc_scope + cdef np.ndarray pos_z = z_bin.astype('float32') * loc_bin_size + loc_bin_size / 2 - loc_scope + + if get_xz_fine: + x_res_l, x_res_r = per_loc_bin_num * 2, per_loc_bin_num * 3 + z_res_l, z_res_r = per_loc_bin_num * 3, per_loc_bin_num * 4 + start_offset = z_res_r + + x_res_norm = pred_reg[:, x_res_l:x_res_r][np.arange(len(x_bin)), x_bin] + z_res_norm = pred_reg[:, z_res_l:z_res_r][np.arange(len(z_bin)), z_bin] + + x_res = x_res_norm * loc_bin_size + z_res = z_res_norm * loc_bin_size + pos_x += x_res + pos_z += z_res + + # recover y localization + if get_y_by_bin: + y_bin_l, y_bin_r = start_offset, start_offset + loc_y_bin_num + y_res_l, y_res_r = y_bin_r, y_bin_r + loc_y_bin_num + start_offset = y_res_r + + y_bin = np.argmax(pred_reg[:, y_bin_l: y_bin_r], axis=1) + y_res_norm = pred_reg[:, y_res_l:y_res_r][np.arange(len(y_bin)), y_bin] + y_res = y_res_norm * loc_y_bin_size + pos_y = y_bin.astype('float32') * loc_y_bin_size + loc_y_bin_size / 2 - loc_y_scope + y_res + pos_y = pos_y + np.array(roi_box3d[:, 1]).reshape(-1) + else: + y_offset_l, y_offset_r = start_offset, start_offset + 1 + start_offset = y_offset_r + + pos_y = np.array(roi_box3d[:, 1]) + np.array(pred_reg[:, y_offset_l]) + pos_y = pos_y.reshape(-1) + + # recover ry rotation + cdef int ry_bin_l = start_offset, + cdef int ry_bin_r = start_offset + num_head_bin + cdef int ry_res_l = ry_bin_r, + cdef int ry_res_r = ry_bin_r + num_head_bin + + cdef np.ndarray ry_bin = np.argmax(pred_reg[:, ry_bin_l: ry_bin_r], axis=1) + cdef np.ndarray ry_res_norm = pred_reg[:, ry_res_l:ry_res_r][np.arange(len(ry_bin)), ry_bin] + if get_ry_fine: + # divide pi/2 into several bins + angle_per_class = (np.pi / 2) / num_head_bin + ry_res = ry_res_norm * (angle_per_class / 2) + ry = (ry_bin.astype('float32') * angle_per_class + angle_per_class / 2) + ry_res - np.pi / 4 + else: + angle_per_class = (2 * np.pi) / num_head_bin + ry_res = ry_res_norm * (angle_per_class / 2) + + # bin_center is (0, 30, 60, 90, 120, ..., 270, 300, 330) + ry = np.fmod(ry_bin.astype('float32') * angle_per_class + ry_res, 2 * np.pi) + ry[ry > np.pi] -= 2 * np.pi + + # recover size + cdef int size_res_l = ry_res_r + cdef int size_res_r = ry_res_r + 3 + assert size_res_r == pred_reg.shape[1] + + cdef np.ndarray size_res_norm = pred_reg[:, size_res_l: size_res_r] + cdef np.ndarray hwl = size_res_norm * anchor_size + anchor_size + + # shift to original coords + cdef np.ndarray roi_center = np.array(roi_box3d[:, 0:3]) + cdef np.ndarray shift_ret_box3d = np.concatenate(( + pos_x.reshape(-1, 1), + pos_y.reshape(-1, 1), + pos_z.reshape(-1, 1), + hwl, ry.reshape(-1, 1)), axis=1) + ret_box3d = shift_ret_box3d + if roi_box3d.shape[1] == 7: + roi_ry = np.array(roi_box3d[:, 6]).reshape(-1) + ret_box3d = _rotate_pc_along_y(np.array(shift_ret_box3d), -roi_ry) + ret_box3d[:, 6] += roi_ry + ret_box3d[:, [0, 2]] += roi_center[:, [0, 2]] + + return ret_box3d diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/cyops/object3d.py b/PaddleCV/Paddle3D/PointRCNN/utils/cyops/object3d.py new file mode 100644 index 00000000..97d81421 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/cyops/object3d.py @@ -0,0 +1,107 @@ +""" +This code is borrow from https://github.com/sshaoshuai/PointRCNN/blob/master/lib/utils/object3d.py +""" +import numpy as np + + +def cls_type_to_id(cls_type): + type_to_id = {'Car': 1, 'Pedestrian': 2, 'Cyclist': 3, 'Van': 4} + if cls_type not in type_to_id.keys(): + return -1 + return type_to_id[cls_type] + + +class Object3d(object): + + def __init__(self, line): + label = line.strip().split(' ') + self.src = line + self.cls_type = label[0] + self.cls_id = cls_type_to_id(self.cls_type) + self.trucation = float(label[1]) + self.occlusion = float(label[2]) # 0:fully visible 1:partly occluded 2:largely occluded 3:unknown + self.alpha = float(label[3]) + self.box2d = np.array((float(label[4]), float(label[5]), float(label[6]), float(label[7])), dtype=np.float32) + self.h = float(label[8]) + self.w = float(label[9]) + self.l = float(label[10]) + self.pos = np.array((float(label[11]), float(label[12]), float(label[13])), dtype=np.float32) + self.dis_to_cam = np.linalg.norm(self.pos) + self.ry = float(label[14]) + self.score = float(label[15]) if label.__len__() == 16 else -1.0 + self.level_str = None + self.level = self.get_obj_level() + + def get_obj_level(self): + height = float(self.box2d[3]) - float(self.box2d[1]) + 1 + + if height >= 40 and self.trucation <= 0.15 and self.occlusion <= 0: + self.level_str = 'Easy' + return 1 # Easy + elif height >= 25 and self.trucation <= 0.3 and self.occlusion <= 1: + self.level_str = 'Moderate' + return 2 # Moderate + elif height >= 25 and self.trucation <= 0.5 and self.occlusion <= 2: + self.level_str = 'Hard' + return 3 # Hard + else: + self.level_str = 'UnKnown' + return 4 + + def generate_corners3d(self): + """ + generate corners3d representation for this object + :return corners_3d: (8, 3) corners of box3d in camera coord + """ + l, h, w = self.l, self.h, self.w + x_corners = [l / 2, l / 2, -l / 2, -l / 2, l / 2, l / 2, -l / 2, -l / 2] + y_corners = [0, 0, 0, 0, -h, -h, -h, -h] + z_corners = [w / 2, -w / 2, -w / 2, w / 2, w / 2, -w / 2, -w / 2, w / 2] + + R = np.array([[np.cos(self.ry), 0, np.sin(self.ry)], + [0, 1, 0], + [-np.sin(self.ry), 0, np.cos(self.ry)]]) + corners3d = np.vstack([x_corners, y_corners, z_corners]) # (3, 8) + corners3d = np.dot(R, corners3d).T + corners3d = corners3d + self.pos + return corners3d + + def to_bev_box2d(self, oblique=True, voxel_size=0.1): + """ + :param bev_shape: (2) for bev shape (h, w), => (y_max, x_max) in image + :param voxel_size: float, 0.1m + :param oblique: + :return: box2d (4, 2)/ (4) in image coordinate + """ + if oblique: + corners3d = self.generate_corners3d() + xz_corners = corners3d[0:4, [0, 2]] + box2d = np.zeros((4, 2), dtype=np.int32) + box2d[:, 0] = ((xz_corners[:, 0] - Object3d.MIN_XZ[0]) / voxel_size).astype(np.int32) + box2d[:, 1] = Object3d.BEV_SHAPE[0] - 1 - ((xz_corners[:, 1] - Object3d.MIN_XZ[1]) / voxel_size).astype(np.int32) + box2d[:, 0] = np.clip(box2d[:, 0], 0, Object3d.BEV_SHAPE[1]) + box2d[:, 1] = np.clip(box2d[:, 1], 0, Object3d.BEV_SHAPE[0]) + else: + box2d = np.zeros(4, dtype=np.int32) + # discrete_center = np.floor((self.pos / voxel_size)).astype(np.int32) + cu = np.floor((self.pos[0] - Object3d.MIN_XZ[0]) / voxel_size).astype(np.int32) + cv = Object3d.BEV_SHAPE[0] - 1 - ((self.pos[2] - Object3d.MIN_XZ[1]) / voxel_size).astype(np.int32) + half_l, half_w = int(self.l / voxel_size / 2), int(self.w / voxel_size / 2) + box2d[0], box2d[1] = cu - half_l, cv - half_w + box2d[2], box2d[3] = cu + half_l, cv + half_w + + return box2d + + def to_str(self): + print_str = '%s %.3f %.3f %.3f box2d: %s hwl: [%.3f %.3f %.3f] pos: %s ry: %.3f' \ + % (self.cls_type, self.trucation, self.occlusion, self.alpha, self.box2d, self.h, self.w, self.l, + self.pos, self.ry) + return print_str + + def to_kitti_format(self): + kitti_str = '%s %.2f %d %.2f %.2f %.2f %.2f %.2f %.2f %.2f %.2f %.2f %.2f %.2f %.2f' \ + % (self.cls_type, self.trucation, int(self.occlusion), self.alpha, self.box2d[0], self.box2d[1], + self.box2d[2], self.box2d[3], self.h, self.w, self.l, self.pos[0], self.pos[1], self.pos[2], + self.ry) + return kitti_str + diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/cyops/roipool3d_utils.pyx b/PaddleCV/Paddle3D/PointRCNN/utils/cyops/roipool3d_utils.pyx new file mode 100644 index 00000000..3efa8313 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/cyops/roipool3d_utils.pyx @@ -0,0 +1,160 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +#Licensed under the Apache License, Version 2.0 (the "License"); +#you may not use this file except in compliance with the License. +#You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +#Unless required by applicable law or agreed to in writing, software +#distributed under the License is distributed on an "AS IS" BASIS, +#WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#See the License for the specific language governing permissions and +#limitations under the License. + +import numpy as np +cimport numpy as np +cimport cython +from libc.math cimport sin, cos + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef enlarge_box3d(np.ndarray boxes3d, int extra_width): + """ + :param boxes3d: (N, 7) [x, y, z, h, w, l, ry] + """ + if isinstance(boxes3d, np.ndarray): + large_boxes3d = boxes3d.copy() + else: + large_boxes3d = boxes3d.clone() + large_boxes3d[:, 3:6] += extra_width * 2 + large_boxes3d[:, 1] += extra_width + return large_boxes3d + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef pt_in_box(float x, float y, float z, float cx, float bottom_y, float cz, float h, float w, float l, float angle): + cdef float max_ids = 10.0 + cdef float cy = bottom_y - h / 2.0 + if ((abs(x - cx) > max_ids) or (abs(y - cy) > h / 2.0) or (abs(z - cz) > max_ids)): + return 0 + cdef float cosa = cos(angle) + cdef float sina = sin(angle) + cdef float x_rot = (x - cx) * cosa + (z - cz) * (-sina) + + cdef float z_rot = (x - cx) * sina + (z - cz) * cosa + + cdef float flag = (x_rot >= -l / 2.0) and (x_rot <= l / 2.0) and (z_rot >= -w / 2.0) and (z_rot <= w / 2.0) + return flag + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef _rotate_pc_along_y(np.ndarray pc, float rot_angle): + """ + params pc: (N, 3+C), (N, 3) is in the rectified camera coordinate + params rot_angle: rad scalar + Output pc: updated pc with XYZ rotated + """ + cosval = np.cos(rot_angle) + sinval = np.sin(rot_angle) + rotmat = np.array([[cosval, -sinval], [sinval, cosval]]) + pc[:, [0, 2]] = np.dot(pc[:, [0, 2]], np.transpose(rotmat)) + return pc + +@cython.boundscheck(False) +@cython.wraparound(False) +def roipool3d_cpu( + np.ndarray[float, ndim=2] pts, + np.ndarray[float, ndim=2] pts_feature, + np.ndarray[float, ndim=2] boxes3d, + np.ndarray[float, ndim=2] pts_extra_input, + int pool_extra_width, int sampled_pt_num, int batch_size=1, bint canonical_transform=False): + cdef np.ndarray pts_feature_all = np.concatenate((pts_extra_input, pts_feature), axis=1) + + cdef np.ndarray larged_boxes3d = enlarge_box3d(boxes3d.reshape(-1, 7), pool_extra_width).reshape(batch_size, -1, 7) + + cdef int pts_num = pts.shape[0], + cdef int boxes_num = boxes3d.shape[0] + cdef int feature_len = pts_feature_all.shape[1] + cdef np.ndarray pts_data = np.zeros((batch_size, boxes_num, sampled_pt_num, 3)) + cdef np.ndarray features_data = np.zeros((batch_size, boxes_num, sampled_pt_num, feature_len)) + cdef np.ndarray empty_flag_data = np.zeros((batch_size, boxes_num)) + + cdef int cnt = 0 + cdef float cx = 0. + cdef float bottom_y = 0. + cdef float cz = 0. + cdef float h = 0. + cdef float w = 0. + cdef float l = 0. + cdef float ry = 0. + cdef float x = 0. + cdef float y = 0. + cdef float z = 0. + cdef np.ndarray x_i + cdef np.ndarray feat_i + cdef int bs + cdef int i + cdef int j + for bs in range(batch_size): + # boxes: 64,7 + for i in range(boxes_num): + cnt = 0 + # box + box = larged_boxes3d[bs][i] + cx = box[0] + bottom_y = box[1] + cz = box[2] + h = box[3] + w = box[4] + l = box[5] + ry = box[6] + # points: 16384,3 + x_i = pts + # features: 16384, 128 + feat_i = pts_feature_all + + for j in range(pts_num): + x = x_i[j][0] + y = x_i[j][1] + z = x_i[j][2] + cur_in_flag = pt_in_box(x,y,z,cx,bottom_y,cz,h,w,l,ry) + if cur_in_flag: + if cnt < sampled_pt_num: + pts_data[bs][i][cnt][:] = x_i[j] + features_data[bs][i][cnt][:] = feat_i[j] + cnt += 1 + else: + break + + if cnt == 0: + empty_flag_data[bs][i] = 1 + elif (cnt < sampled_pt_num): + for k in range(cnt, sampled_pt_num): + pts_data[bs][i][k] = pts_data[bs][i][k % cnt] + features_data[bs][i][k] = features_data[bs][i][k % cnt] + + + pooled_pts = pts_data.astype("float32")[0] + pooled_features = features_data.astype('float32')[0] + pooled_empty_flag = empty_flag_data.astype('int64')[0] + + cdef int extra_input_len = pts_extra_input.shape[1] + pooled_pts = np.concatenate((pooled_pts, pooled_features[:,:,0:extra_input_len]),axis=2) + pooled_features = pooled_features[:,:,extra_input_len:] + + if canonical_transform: + # Translate to the roi coordinates + roi_ry = boxes3d[:, 6] % (2 * np.pi) # 0~2pi + roi_center = boxes3d[:, 0:3] + # shift to center + pooled_pts[:, :, 0:3] = pooled_pts[:, :, 0:3] - roi_center[:, np.newaxis, :] + for k in range(pooled_pts.shape[0]): + pooled_pts[k] = _rotate_pc_along_y(pooled_pts[k], roi_ry[k]) + return pooled_pts, pooled_features, pooled_empty_flag + + return pooled_pts, pooled_features, pooled_empty_flag + + +#def roipool3d_cpu(pts, pts_feature, boxes3d, pts_extra_input, pool_extra_width, sampled_pt_num=512, batch_size=1): +# return _roipool3d_cpu(pts, pts_feature, boxes3d, pts_extra_input, pool_extra_width, sampled_pt_num, batch_size) diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/cyops/setup.py b/PaddleCV/Paddle3D/PointRCNN/utils/cyops/setup.py new file mode 100644 index 00000000..0d775017 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/cyops/setup.py @@ -0,0 +1,74 @@ +# Copyright (c) 2017-present, Facebook, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +############################################################################## + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +from Cython.Build import cythonize +from setuptools import Extension +from setuptools import setup + +import numpy as np + +_NP_INCLUDE_DIRS = np.get_include() + + +# Extension modules +ext_modules = [ + Extension( + name='utils.cyops.roipool3d_utils', + sources=[ + 'utils/cyops/roipool3d_utils.pyx' + ], + extra_compile_args=[ + '-Wno-cpp' + ], + include_dirs=[ + _NP_INCLUDE_DIRS + ] + ), + + Extension( + name='utils.cyops.iou3d_utils', + sources=[ + 'utils/cyops/iou3d_utils.pyx' + ], + extra_compile_args=[ + '-Wno-cpp' + ], + include_dirs=[ + _NP_INCLUDE_DIRS + ] + ), + + Extension( + name='utils.cyops.kitti_utils', + sources=[ + 'utils/cyops/kitti_utils.pyx' + ], + extra_compile_args=[ + '-Wno-cpp' + ], + include_dirs=[ + _NP_INCLUDE_DIRS + ] + ), +] + +setup( + name='pp_pointrcnn', + ext_modules=cythonize(ext_modules) +) diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/metric_utils.py b/PaddleCV/Paddle3D/PointRCNN/utils/metric_utils.py new file mode 100644 index 00000000..aa7ee706 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/metric_utils.py @@ -0,0 +1,216 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import sys +import logging +import numpy as np +import utils.cyops.kitti_utils as kitti_utils +from utils.config import cfg +from utils.box_utils import boxes_iou3d, box_nms_eval, boxes3d_to_bev +from utils.save_utils import save_rpn_feature, save_kitti_result, save_kitti_format + +__all__ = ['calc_iou_recall', 'rpn_metric', 'rcnn_metric'] + +logging.root.handlers = [] +FORMAT = '%(asctime)s-%(levelname)s: %(message)s' +logging.basicConfig(level=logging.INFO, format=FORMAT, stream=sys.stdout) +logger = logging.getLogger(__name__) + + +def calc_iou_recall(rets, thresh_list): + rpn_cls_label = rets['rpn_cls_label'][0] + boxes3d = rets['rois'][0] + seg_mask = rets['seg_mask'][0] + sample_id = rets['sample_id'][0] + gt_boxes3d = rets['gt_boxes3d'][0] + gt_boxes3d_num = rets['gt_boxes3d'][1] + + gt_box_idx = 0 + recalled_bbox_list = [0] * len(thresh_list) + gt_box_num = 0 + rpn_iou_sum = 0. + for i in range(len(gt_boxes3d_num)): + cur_rpn_cls_label = rpn_cls_label[i] + cur_boxes3d = boxes3d[i] + cur_seg_mask = seg_mask[i] + cur_sample_id = sample_id[i] + cur_gt_boxes3d = gt_boxes3d[gt_box_idx: gt_box_idx + + gt_boxes3d_num[0][i]] + gt_box_idx += gt_boxes3d_num[0][i] + + k = cur_gt_boxes3d.__len__() - 1 + while k >= 0 and np.sum(cur_gt_boxes3d[k]) == 0: + k -= 1 + cur_gt_boxes3d = cur_gt_boxes3d[:k + 1] + + if cur_gt_boxes3d.shape[0] > 0: + iou3d = boxes_iou3d(cur_boxes3d, cur_gt_boxes3d[:, 0:7]) + gt_max_iou = iou3d.max(axis=0) + + for idx, thresh in enumerate(thresh_list): + recalled_bbox_list[idx] += np.sum(gt_max_iou > thresh) + gt_box_num += cur_gt_boxes3d.__len__() + + fg_mask = cur_rpn_cls_label > 0 + correct = np.sum(np.logical_and( + cur_seg_mask == cur_rpn_cls_label, fg_mask)) + union = np.sum(fg_mask) + np.sum(cur_seg_mask > 0) - correct + rpn_iou = float(correct) / max(float(union), 1.0) + rpn_iou_sum += rpn_iou + logger.debug('sample_id:{}, rpn_iou:{}, gt_box_num:{}, recalled_bbox_list:{}'.format( + sample_id, rpn_iou, gt_box_num, str(recalled_bbox_list))) + + return len(gt_boxes3d_num), gt_box_num, rpn_iou_sum, recalled_bbox_list + + +def rpn_metric(queue, mdict, lock, thresh_list, is_save_rpn_feature, kitti_feature_dir, + seg_output_dir, kitti_output_dir, kitti_rcnn_reader, classes): + while True: + rets_dict = queue.get() + if rets_dict is None: + lock.acquire() + mdict['exit_proc'] += 1 + lock.release() + return + + cnt, gt_box_num, rpn_iou_sum, recalled_bbox_list = calc_iou_recall( + rets_dict, thresh_list) + lock.acquire() + mdict['total_cnt'] += cnt + mdict['total_gt_bbox'] += gt_box_num + mdict['total_rpn_iou'] += rpn_iou_sum + for i, bbox_num in enumerate(recalled_bbox_list): + mdict['total_recalled_bbox_list_{}'.format(i)] += bbox_num + logger.debug("rpn_metric: {}".format(str(mdict))) + lock.release() + + if is_save_rpn_feature: + save_rpn_feature(rets_dict, kitti_feature_dir) + save_kitti_result( + rets_dict, seg_output_dir, kitti_output_dir, kitti_rcnn_reader, classes) + + +def rcnn_metric(queue, mdict, lock, thresh_list, kitti_rcnn_reader, roi_output_dir, + refine_output_dir, final_output_dir, is_save_result=False): + while True: + rets_dict = queue.get() + if rets_dict is None: + lock.acquire() + mdict['exit_proc'] += 1 + lock.release() + return + + for k,v in rets_dict.items(): + rets_dict[k] = v[0] + + rcnn_cls = rets_dict['rcnn_cls'] + rcnn_reg = rets_dict['rcnn_reg'] + roi_boxes3d = rets_dict['roi_boxes3d'] + roi_scores = rets_dict['roi_scores'] + + # bounding box regression + anchor_size = cfg.CLS_MEAN_SIZE[0] + pred_boxes3d = kitti_utils.decode_bbox_target( + roi_boxes3d, + rcnn_reg, + anchor_size=np.array(anchor_size), + loc_scope=cfg.RCNN.LOC_SCOPE, + loc_bin_size=cfg.RCNN.LOC_BIN_SIZE, + num_head_bin=cfg.RCNN.NUM_HEAD_BIN, + get_xz_fine=True, + get_y_by_bin=cfg.RCNN.LOC_Y_BY_BIN, + loc_y_scope=cfg.RCNN.LOC_Y_SCOPE, + loc_y_bin_size=cfg.RCNN.LOC_Y_BIN_SIZE, + get_ry_fine=True + ) + + # scoring + if rcnn_cls.shape[1] == 1: + raw_scores = rcnn_cls.reshape(-1) + norm_scores = rets_dict['norm_scores'] + pred_classes = norm_scores > cfg.RCNN.SCORE_THRESH + pred_classes = pred_classes.astype(np.float32) + else: + pred_classes = np.argmax(rcnn_cls, axis=1).reshape(-1) + raw_scores = rcnn_cls[:, pred_classes] + + # evaluation + gt_iou = rets_dict['gt_iou'] + gt_boxes3d = rets_dict['gt_boxes3d'] + + # recall + if gt_boxes3d.size > 0: + gt_num = gt_boxes3d.shape[1] + gt_boxes3d = gt_boxes3d.reshape((-1,7)) + iou3d = boxes_iou3d(pred_boxes3d, gt_boxes3d) + gt_max_iou = iou3d.max(axis=0) + refined_iou = iou3d.max(axis=1) + + recalled_num = (gt_max_iou > 0.7).sum() + roi_boxes3d = roi_boxes3d.reshape((-1,7)) + iou3d_in = boxes_iou3d(roi_boxes3d, gt_boxes3d) + gt_max_iou_in = iou3d_in.max(axis=0) + + lock.acquire() + mdict['total_gt_bbox'] += gt_num + for idx, thresh in enumerate(thresh_list): + recalled_bbox_num = (gt_max_iou > thresh).sum() + mdict['total_recalled_bbox_list_{}'.format(idx)] += recalled_bbox_num + for idx, thresh in enumerate(thresh_list): + roi_recalled_bbox_num = (gt_max_iou_in > thresh).sum() + mdict['total_roi_recalled_bbox_list_{}'.format(idx)] += roi_recalled_bbox_num + lock.release() + + # classification accuracy + cls_label = gt_iou > cfg.RCNN.CLS_FG_THRESH + cls_label = cls_label.astype(np.float32) + cls_valid_mask = (gt_iou >= cfg.RCNN.CLS_FG_THRESH) | (gt_iou <= cfg.RCNN.CLS_BG_THRESH) + cls_valid_mask = cls_valid_mask.astype(np.float32) + cls_acc = (pred_classes == cls_label).astype(np.float32) + cls_acc = (cls_acc * cls_valid_mask).sum() / max(cls_valid_mask.sum(), 1.0) * 1.0 + + iou_thresh = 0.7 if cfg.CLASSES == 'Car' else 0.5 + cls_label_refined = (gt_iou >= iou_thresh) + cls_label_refined = cls_label_refined.astype(np.float32) + cls_acc_refined = (pred_classes == cls_label_refined).astype(np.float32).sum() / max(cls_label_refined.shape[0], 1.0) + + sample_id = rets_dict['sample_id'] + image_shape = kitti_rcnn_reader.get_image_shape(sample_id) + + if is_save_result: + roi_boxes3d_np = roi_boxes3d + pred_boxes3d_np = pred_boxes3d + calib = kitti_rcnn_reader.get_calib(sample_id) + save_kitti_format(sample_id, calib, roi_boxes3d_np, roi_output_dir, roi_scores, image_shape) + save_kitti_format(sample_id, calib, pred_boxes3d_np, refine_output_dir, raw_scores, image_shape) + + inds = norm_scores > cfg.RCNN.SCORE_THRESH + if inds.astype(np.float32).sum() == 0: + logger.debug("The num of 'norm_scores > thresh' of sample {} is 0".format(sample_id)) + continue + pred_boxes3d_selected = pred_boxes3d[inds] + raw_scores_selected = raw_scores[inds] + # NMS thresh + boxes_bev_selected = boxes3d_to_bev(pred_boxes3d_selected) + scores_selected, pred_boxes3d_selected = box_nms_eval(boxes_bev_selected, raw_scores_selected, pred_boxes3d_selected, cfg.RCNN.NMS_THRESH) + calib = kitti_rcnn_reader.get_calib(sample_id) + save_kitti_format(sample_id, calib, pred_boxes3d_selected, final_output_dir, scores_selected, image_shape) + lock.acquire() + mdict['total_det_num'] += pred_boxes3d_selected.shape[0] + mdict['total_cls_acc'] += cls_acc + mdict['total_cls_acc_refined'] += cls_acc_refined + lock.release() + logger.debug("rcnn_metric: {}".format(str(mdict))) + diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/object3d.py b/PaddleCV/Paddle3D/PointRCNN/utils/object3d.py new file mode 100644 index 00000000..7b5703bd --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/object3d.py @@ -0,0 +1,113 @@ +""" +This code is borrow from https://github.com/sshaoshuai/PointRCNN/blob/master/lib/utils/object3d.py +""" +import numpy as np + + +def cls_type_to_id(cls_type): + type_to_id = {'Car': 1, 'Pedestrian': 2, 'Cyclist': 3, 'Van': 4} + if cls_type not in type_to_id.keys(): + return -1 + return type_to_id[cls_type] + + +def get_objects_from_label(label_file): + with open(label_file, 'r') as f: + lines = f.readlines() + objects = [Object3d(line) for line in lines] + return objects + + +class Object3d(object): + def __init__(self, line): + label = line.strip().split(' ') + self.src = line + self.cls_type = label[0] + self.cls_id = cls_type_to_id(self.cls_type) + self.trucation = float(label[1]) + self.occlusion = float(label[2]) # 0:fully visible 1:partly occluded 2:largely occluded 3:unknown + self.alpha = float(label[3]) + self.box2d = np.array((float(label[4]), float(label[5]), float(label[6]), float(label[7])), dtype=np.float32) + self.h = float(label[8]) + self.w = float(label[9]) + self.l = float(label[10]) + self.pos = np.array((float(label[11]), float(label[12]), float(label[13])), dtype=np.float32) + self.dis_to_cam = np.linalg.norm(self.pos) + self.ry = float(label[14]) + self.score = float(label[15]) if label.__len__() == 16 else -1.0 + self.level_str = None + self.level = self.get_obj_level() + + def get_obj_level(self): + height = float(self.box2d[3]) - float(self.box2d[1]) + 1 + + if height >= 40 and self.trucation <= 0.15 and self.occlusion <= 0: + self.level_str = 'Easy' + return 1 # Easy + elif height >= 25 and self.trucation <= 0.3 and self.occlusion <= 1: + self.level_str = 'Moderate' + return 2 # Moderate + elif height >= 25 and self.trucation <= 0.5 and self.occlusion <= 2: + self.level_str = 'Hard' + return 3 # Hard + else: + self.level_str = 'UnKnown' + return 4 + + def generate_corners3d(self): + """ + generate corners3d representation for this object + :return corners_3d: (8, 3) corners of box3d in camera coord + """ + l, h, w = self.l, self.h, self.w + x_corners = [l / 2, l / 2, -l / 2, -l / 2, l / 2, l / 2, -l / 2, -l / 2] + y_corners = [0, 0, 0, 0, -h, -h, -h, -h] + z_corners = [w / 2, -w / 2, -w / 2, w / 2, w / 2, -w / 2, -w / 2, w / 2] + + R = np.array([[np.cos(self.ry), 0, np.sin(self.ry)], + [0, 1, 0], + [-np.sin(self.ry), 0, np.cos(self.ry)]]) + corners3d = np.vstack([x_corners, y_corners, z_corners]) # (3, 8) + corners3d = np.dot(R, corners3d).T + corners3d = corners3d + self.pos + return corners3d + + def to_bev_box2d(self, oblique=True, voxel_size=0.1): + """ + :param bev_shape: (2) for bev shape (h, w), => (y_max, x_max) in image + :param voxel_size: float, 0.1m + :param oblique: + :return: box2d (4, 2)/ (4) in image coordinate + """ + if oblique: + corners3d = self.generate_corners3d() + xz_corners = corners3d[0:4, [0, 2]] + box2d = np.zeros((4, 2), dtype=np.int32) + box2d[:, 0] = ((xz_corners[:, 0] - Object3d.MIN_XZ[0]) / voxel_size).astype(np.int32) + box2d[:, 1] = Object3d.BEV_SHAPE[0] - 1 - ((xz_corners[:, 1] - Object3d.MIN_XZ[1]) / voxel_size).astype(np.int32) + box2d[:, 0] = np.clip(box2d[:, 0], 0, Object3d.BEV_SHAPE[1]) + box2d[:, 1] = np.clip(box2d[:, 1], 0, Object3d.BEV_SHAPE[0]) + else: + box2d = np.zeros(4, dtype=np.int32) + # discrete_center = np.floor((self.pos / voxel_size)).astype(np.int32) + cu = np.floor((self.pos[0] - Object3d.MIN_XZ[0]) / voxel_size).astype(np.int32) + cv = Object3d.BEV_SHAPE[0] - 1 - ((self.pos[2] - Object3d.MIN_XZ[1]) / voxel_size).astype(np.int32) + half_l, half_w = int(self.l / voxel_size / 2), int(self.w / voxel_size / 2) + box2d[0], box2d[1] = cu - half_l, cv - half_w + box2d[2], box2d[3] = cu + half_l, cv + half_w + + return box2d + + def to_str(self): + print_str = '%s %.3f %.3f %.3f box2d: %s hwl: [%.3f %.3f %.3f] pos: %s ry: %.3f' \ + % (self.cls_type, self.trucation, self.occlusion, self.alpha, self.box2d, self.h, self.w, self.l, + self.pos, self.ry) + return print_str + + def to_kitti_format(self): + kitti_str = '%s %.2f %d %.2f %.2f %.2f %.2f %.2f %.2f %.2f %.2f %.2f %.2f %.2f %.2f' \ + % (self.cls_type, self.trucation, int(self.occlusion), self.alpha, self.box2d[0], self.box2d[1], + self.box2d[2], self.box2d[3], self.h, self.w, self.l, self.pos[0], self.pos[1], self.pos[2], + self.ry) + return kitti_str + diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/optimizer.py b/PaddleCV/Paddle3D/PointRCNN/utils/optimizer.py new file mode 100644 index 00000000..e32d1df8 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/optimizer.py @@ -0,0 +1,122 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Optimization and learning rate scheduling.""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import numpy as np +import paddle.fluid as fluid +import paddle.fluid.layers.learning_rate_scheduler as lr_scheduler +from paddle.fluid.layers import control_flow + +import logging +logger = logging.getLogger(__name__) + +def cosine_warmup_decay(learning_rate, betas, warmup_factor, decay_factor, + total_step, warmup_pct): + def annealing_cos(start, end, pct): + "Cosine anneal from `start` to `end` as pct goes from 0.0 to 1.0." + cos_out = fluid.layers.cos(pct * np.pi) + 1. + return cos_out * (start - end) / 2. + end + + warmup_start_lr = learning_rate * warmup_factor + decay_end_lr = learning_rate * decay_factor + warmup_step = total_step * warmup_pct + + global_step = lr_scheduler._decay_step_counter() + + lr = fluid.layers.create_global_var( + shape=[1], + value=float(learning_rate), + dtype='float32', + persistable=True, + name="learning_rate") + beta1 = fluid.layers.create_global_var( + shape=[1], + value=float(betas[0]), + dtype='float32', + persistable=True, + name="beta1") + + warmup_step_var = fluid.layers.fill_constant( + shape=[1], dtype='float32', value=float(warmup_step), force_cpu=True) + + with control_flow.Switch() as switch: + with switch.case(global_step < warmup_step_var): + cur_lr = annealing_cos(warmup_start_lr, learning_rate, + global_step / warmup_step_var) + fluid.layers.assign(cur_lr, lr) + cur_beta1 = annealing_cos(betas[0], betas[1], + global_step / warmup_step_var) + fluid.layers.assign(cur_beta1, beta1) + with switch.case(global_step >= warmup_step_var): + cur_lr = annealing_cos(learning_rate, decay_end_lr, + (global_step - warmup_step_var) / (total_step - warmup_step)) + fluid.layers.assign(cur_lr, lr) + cur_beta1 = annealing_cos(betas[1], betas[0], + (global_step - warmup_step_var) / (total_step - warmup_step)) + fluid.layers.assign(cur_beta1, beta1) + + return lr, beta1 + + +def optimize(loss, + learning_rate, + warmup_factor, + decay_factor, + total_step, + warmup_pct, + train_program, + startup_prog, + weight_decay, + clip_norm, + beta1=[0.95, 0.85], + beta2=0.99, + scheduler='cosine_warmup_decay'): + + scheduled_lr= None + if scheduler == 'cosine_warmup_decay': + scheduled_lr, scheduled_beta1 = cosine_warmup_decay(learning_rate, beta1, warmup_factor, + decay_factor, total_step, + warmup_pct) + else: + raise ValueError("Unkown learning rate scheduler, should be " + "'cosine_warmup_decay'") + + optimizer = fluid.optimizer.Adam(learning_rate=scheduled_lr, + beta1=scheduled_beta1, + beta2=beta2) + fluid.clip.set_gradient_clip( + clip=fluid.clip.GradientClipByGlobalNorm(clip_norm=clip_norm)) + + param_list = dict() + + if weight_decay > 0: + for param in train_program.global_block().all_parameters(): + param_list[param.name] = param * 1.0 + param_list[param.name].stop_gradient = True + + _, param_grads = optimizer.minimize(loss) + + if weight_decay > 0: + for param, grad in param_grads: + with param.block.program._optimized_guard( + [param, grad]), fluid.framework.name_scope("weight_decay"): + updated_param = param - param_list[ + param.name] * weight_decay * scheduled_lr + fluid.layers.assign(output=param, input=updated_param) + + return scheduled_lr diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/proposal_target.py b/PaddleCV/Paddle3D/PointRCNN/utils/proposal_target.py new file mode 100644 index 00000000..deda5118 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/proposal_target.py @@ -0,0 +1,369 @@ +import numpy as np +from utils.cyops import kitti_utils, roipool3d_utils, iou3d_utils + +CLOSE_RANDOM = False + +def get_proposal_target_func(cfg, mode='TRAIN'): + + def sample_rois_for_rcnn(roi_boxes3d, gt_boxes3d): + """ + :param roi_boxes3d: (B, M, 7) + :param gt_boxes3d: (B, N, 8) [x, y, z, h, w, l, ry, cls] + :return + batch_rois: (B, N, 7) + batch_gt_of_rois: (B, N, 8) + batch_roi_iou: (B, N) + """ + + batch_size = roi_boxes3d.shape[0] + + #batch_size = 1 + fg_rois_per_image = int(np.round(cfg.RCNN.FG_RATIO * cfg.RCNN.ROI_PER_IMAGE)) + + batch_rois = np.zeros((batch_size, cfg.RCNN.ROI_PER_IMAGE, 7)) + batch_gt_of_rois = np.zeros((batch_size, cfg.RCNN.ROI_PER_IMAGE, 7)) + batch_roi_iou = np.zeros((batch_size, cfg.RCNN.ROI_PER_IMAGE)) + for idx in range(batch_size): + cur_roi, cur_gt = roi_boxes3d[idx], gt_boxes3d[idx] + k = cur_gt.shape[0] - 1 + while cur_gt[k].sum() == 0: + k -= 1 + cur_gt = cur_gt[:k + 1] + # include gt boxes in the candidate rois + iou3d = iou3d_utils.boxes_iou3d(cur_roi, cur_gt[:, 0:7]) # (M, N) + max_overlaps = np.max(iou3d, axis=1) + gt_assignment = np.argmax(iou3d, axis=1) + # sample fg, easy_bg, hard_bg + fg_thresh = min(cfg.RCNN.REG_FG_THRESH, cfg.RCNN.CLS_FG_THRESH) + fg_inds = np.where(max_overlaps >= fg_thresh)[0].reshape(-1) + + # TODO: this will mix the fg and bg when CLS_BG_THRESH_LO < iou < CLS_BG_THRESH + # fg_inds = torch.cat((fg_inds, roi_assignment), dim=0) # consider the roi which has max_iou with gt as fg + easy_bg_inds = np.where(max_overlaps < cfg.RCNN.CLS_BG_THRESH_LO)[0].reshape(-1) + hard_bg_inds = np.where((max_overlaps < cfg.RCNN.CLS_BG_THRESH) & (max_overlaps >= cfg.RCNN.CLS_BG_THRESH_LO))[0].reshape(-1) + + fg_num_rois = fg_inds.shape[0] + bg_num_rois = hard_bg_inds.shape[0] + easy_bg_inds.shape[0] + + if fg_num_rois > 0 and bg_num_rois > 0: + # sampling fg + fg_rois_per_this_image = min(fg_rois_per_image, fg_num_rois) + if CLOSE_RANDOM: + fg_inds = fg_inds[:fg_rois_per_this_image] + else: + rand_num = np.random.permutation(fg_num_rois) + fg_inds = fg_inds[rand_num[:fg_rois_per_this_image]] + + # sampling bg + bg_rois_per_this_image = cfg.RCNN.ROI_PER_IMAGE - fg_rois_per_this_image + bg_inds = sample_bg_inds(hard_bg_inds, easy_bg_inds, bg_rois_per_this_image) + + elif fg_num_rois > 0 and bg_num_rois == 0: + # sampling fg + rand_num = np.floor(np.random.rand(cfg.RCNN.ROI_PER_IMAGE) * fg_num_rois) + # rand_num = torch.from_numpy(rand_num).type_as(gt_boxes3d).long() + fg_inds = fg_inds[rand_num] + fg_rois_per_this_image = cfg.RCNN.ROI_PER_IMAGE + bg_rois_per_this_image = 0 + elif bg_num_rois > 0 and fg_num_rois == 0: + # sampling bg + bg_rois_per_this_image = cfg.RCNN.ROI_PER_IMAGE + bg_inds = sample_bg_inds(hard_bg_inds, easy_bg_inds, bg_rois_per_this_image) + + fg_rois_per_this_image = 0 + else: + import pdb + pdb.set_trace() + raise NotImplementedError + # augment the rois by noise + roi_list, roi_iou_list, roi_gt_list = [], [], [] + if fg_rois_per_this_image > 0: + fg_rois_src = cur_roi[fg_inds] + gt_of_fg_rois = cur_gt[gt_assignment[fg_inds]] + iou3d_src = max_overlaps[fg_inds] + fg_rois, fg_iou3d = aug_roi_by_noise( + fg_rois_src, gt_of_fg_rois, iou3d_src, aug_times=cfg.RCNN.ROI_FG_AUG_TIMES) + roi_list.append(fg_rois) + roi_iou_list.append(fg_iou3d) + roi_gt_list.append(gt_of_fg_rois) + + if bg_rois_per_this_image > 0: + bg_rois_src = cur_roi[bg_inds] + gt_of_bg_rois = cur_gt[gt_assignment[bg_inds]] + iou3d_src = max_overlaps[bg_inds] + aug_times = 1 if cfg.RCNN.ROI_FG_AUG_TIMES > 0 else 0 + bg_rois, bg_iou3d = aug_roi_by_noise( + bg_rois_src, gt_of_bg_rois, iou3d_src, aug_times=aug_times) + roi_list.append(bg_rois) + roi_iou_list.append(bg_iou3d) + roi_gt_list.append(gt_of_bg_rois) + + + rois = np.concatenate(roi_list, axis=0) + iou_of_rois = np.concatenate(roi_iou_list, axis=0) + gt_of_rois = np.concatenate(roi_gt_list, axis=0) + batch_rois[idx] = rois + batch_gt_of_rois[idx] = gt_of_rois + batch_roi_iou[idx] = iou_of_rois + + return batch_rois, batch_gt_of_rois, batch_roi_iou + + def sample_bg_inds(hard_bg_inds, easy_bg_inds, bg_rois_per_this_image): + + if hard_bg_inds.shape[0] > 0 and easy_bg_inds.shape[0] > 0: + hard_bg_rois_num = int(bg_rois_per_this_image * cfg.RCNN.HARD_BG_RATIO) + easy_bg_rois_num = bg_rois_per_this_image - hard_bg_rois_num + # sampling hard bg + if CLOSE_RANDOM: + rand_idx = list(np.arange(0,hard_bg_inds.shape[0]))*hard_bg_rois_num + rand_idx = rand_idx[:hard_bg_rois_num] + else: + rand_idx = np.random.randint(low=0, high=hard_bg_inds.shape[0], size=(hard_bg_rois_num,)) + hard_bg_inds = hard_bg_inds[rand_idx] + # sampling easy bg + if CLOSE_RANDOM: + rand_idx = list(np.arange(0,easy_bg_inds.shape[0]))*easy_bg_rois_num + rand_idx = rand_idx[:easy_bg_rois_num] + else: + rand_idx = np.random.randint(low=0, high=easy_bg_inds.shape[0], size=(easy_bg_rois_num,)) + easy_bg_inds = easy_bg_inds[rand_idx] + bg_inds = np.concatenate([hard_bg_inds, easy_bg_inds], axis=0) + elif hard_bg_inds.shape[0] > 0 and easy_bg_inds.shape[0] == 0: + hard_bg_rois_num = bg_rois_per_this_image + # sampling hard bg + rand_idx = np.random.randint(low=0, high=hard_bg_inds.shape[0], size=(hard_bg_rois_num,)) + bg_inds = hard_bg_inds[rand_idx] + elif hard_bg_inds.shape[0] == 0 and easy_bg_inds.shape[0] > 0: + easy_bg_rois_num = bg_rois_per_this_image + # sampling easy bg + rand_idx = np.random.randint(low=0, high=easy_bg_inds.shape[0], size=(easy_bg_rois_num,)) + bg_inds = easy_bg_inds[rand_idx] + else: + raise NotImplementedError + + return bg_inds + + def aug_roi_by_noise(roi_boxes3d, gt_boxes3d, iou3d_src, aug_times=10): + iou_of_rois = np.zeros(roi_boxes3d.shape[0]).astype(gt_boxes3d.dtype) + pos_thresh = min(cfg.RCNN.REG_FG_THRESH, cfg.RCNN.CLS_FG_THRESH) + + for k in range(roi_boxes3d.shape[0]): + temp_iou = cnt = 0 + roi_box3d = roi_boxes3d[k] + + gt_box3d = gt_boxes3d[k].reshape(1, 7) + aug_box3d = roi_box3d + keep = True + while temp_iou < pos_thresh and cnt < aug_times: + if True: #np.random.rand() < 0.2: + aug_box3d = roi_box3d # p=0.2 to keep the original roi box + keep = True + else: + aug_box3d = random_aug_box3d(roi_box3d) + keep = False + aug_box3d = aug_box3d.reshape((1, 7)) + iou3d = iou3d_utils.boxes_iou3d(aug_box3d, gt_box3d) + temp_iou = iou3d[0][0] + cnt += 1 + roi_boxes3d[k] = aug_box3d.reshape(-1) + if cnt == 0 or keep: + iou_of_rois[k] = iou3d_src[k] + else: + iou_of_rois[k] = temp_iou + return roi_boxes3d, iou_of_rois + + def random_aug_box3d(box3d): + """ + :param box3d: (7) [x, y, z, h, w, l, ry] + random shift, scale, orientation + """ + if cfg.RCNN.REG_AUG_METHOD == 'single': + + pos_shift = (np.random.rand(3) - 0.5) # [-0.5 ~ 0.5] + hwl_scale = (np.random.rand(3) - 0.5) / (0.5 / 0.15) + 1.0 # + angle_rot = (np.random.rand(1) - 0.5) / (0.5 / (np.pi / 12)) # [-pi/12 ~ pi/12] + aug_box3d = np.concatenate([box3d[0:3] + pos_shift, box3d[3:6] * hwl_scale, box3d[6:7] + angle_rot], axis=0) + return aug_box3d + elif cfg.RCNN.REG_AUG_METHOD == 'multiple': + # pos_range, hwl_range, angle_range, mean_iou + range_config = [[0.2, 0.1, np.pi / 12, 0.7], + [0.3, 0.15, np.pi / 12, 0.6], + [0.5, 0.15, np.pi / 9, 0.5], + [0.8, 0.15, np.pi / 6, 0.3], + [1.0, 0.15, np.pi / 3, 0.2]] + idx = np.random.randint(low=0, high=len(range_config), size=(1,))[0] + pos_shift = ((np.random.rand(3) - 0.5) / 0.5) * range_config[idx][0] + hwl_scale = ((np.random.rand(3) - 0.5) / 0.5) * range_config[idx][1] + 1.0 + angle_rot = ((np.random.rand(1) - 0.5) / 0.5) * range_config[idx][2] + aug_box3d = np.concatenate([box3d[0:3] + pos_shift, box3d[3:6] * hwl_scale, box3d[6:7] + angle_rot], axis=0) + return aug_box3d + elif cfg.RCNN.REG_AUG_METHOD == 'normal': + x_shift = np.random.normal(loc=0, scale=0.3) + y_shift = np.random.normal(loc=0, scale=0.2) + z_shift = np.random.normal(loc=0, scale=0.3) + h_shift = np.random.normal(loc=0, scale=0.25) + w_shift = np.random.normal(loc=0, scale=0.15) + l_shift = np.random.normal(loc=0, scale=0.5) + ry_shift = ((np.random.rand() - 0.5) / 0.5) * np.pi / 12 + aug_box3d = np.array([box3d[0] + x_shift, box3d[1] + y_shift, box3d[2] + z_shift, box3d[3] + h_shift, + box3d[4] + w_shift, box3d[5] + l_shift, box3d[6] + ry_shift], dtype=np.float32) + aug_box3d = aug_box3d.astype(box3d.dtype) + return aug_box3d + else: + raise NotImplementedError + + def data_augmentation(pts, rois, gt_of_rois): + """ + :param pts: (B, M, 512, 3) + :param rois: (B, M. 7) + :param gt_of_rois: (B, M, 7) + :return: + """ + batch_size, boxes_num = pts.shape[0], pts.shape[1] + + # rotation augmentation + angles = (np.random.rand(batch_size, boxes_num) - 0.5 / 0.5) * (np.pi / cfg.AUG_ROT_RANGE) + # calculate gt alpha from gt_of_rois + temp_x, temp_z, temp_ry = gt_of_rois[:, :, 0], gt_of_rois[:, :, 2], gt_of_rois[:, :, 6] + temp_beta = np.arctan2(temp_z, temp_x) + gt_alpha = -np.sign(temp_beta) * np.pi / 2 + temp_beta + temp_ry # (B, M) + + temp_x, temp_z, temp_ry = rois[:, :, 0], rois[:, :, 2], rois[:, :, 6] + temp_beta = np.arctan2(temp_z, temp_x) + roi_alpha = -np.sign(temp_beta) * np.pi / 2 + temp_beta + temp_ry # (B, M) + + for k in range(batch_size): + pts[k] = kitti_utils.rotate_pc_along_y_np(pts[k], angles[k]) + gt_of_rois[k] = np.squeeze(kitti_utils.rotate_pc_along_y_np( + np.expand_dims(gt_of_rois[k], axis=1), angles[k]), axis=1) + rois[k] = np.squeeze(kitti_utils.rotate_pc_along_y_np( + np.expand_dims(rois[k], axis=1), angles[k]),axis=1) + + # calculate the ry after rotation + temp_x, temp_z = gt_of_rois[:, :, 0], gt_of_rois[:, :, 2] + temp_beta = np.arctan2(temp_z, temp_x) + gt_of_rois[:, :, 6] = np.sign(temp_beta) * np.pi / 2 + gt_alpha - temp_beta + temp_x, temp_z = rois[:, :, 0], rois[:, :, 2] + temp_beta = np.arctan2(temp_z, temp_x) + rois[:, :, 6] = np.sign(temp_beta) * np.pi / 2 + roi_alpha - temp_beta + # scaling augmentation + scales = 1 + ((np.random.rand(batch_size, boxes_num) - 0.5) / 0.5) * 0.05 + pts = pts * np.expand_dims(np.expand_dims(scales, axis=2), axis=3) + gt_of_rois[:, :, 0:6] = gt_of_rois[:, :, 0:6] * np.expand_dims(scales, axis=2) + rois[:, :, 0:6] = rois[:, :, 0:6] * np.expand_dims(scales, axis=2) + + # flip augmentation + flip_flag = np.sign(np.random.rand(batch_size, boxes_num) - 0.5) + pts[:, :, :, 0] = pts[:, :, :, 0] * np.expand_dims(flip_flag, axis=2) + gt_of_rois[:, :, 0] = gt_of_rois[:, :, 0] * flip_flag + # flip orientation: ry > 0: pi - ry, ry < 0: -pi - ry + src_ry = gt_of_rois[:, :, 6] + ry = (flip_flag == 1).astype(np.float32) * src_ry + (flip_flag == -1).astype(np.float32) * (np.sign(src_ry) * np.pi - src_ry) + gt_of_rois[:, :, 6] = ry + + rois[:, :, 0] = rois[:, :, 0] * flip_flag + # flip orientation: ry > 0: pi - ry, ry < 0: -pi - ry + src_ry = rois[:, :, 6] + ry = (flip_flag == 1).astype(np.float32) * src_ry + (flip_flag == -1).astype(np.float32) * (np.sign(src_ry) * np.pi - src_ry) + rois[:, :, 6] = ry + + return pts, rois, gt_of_rois + + def generate_proposal_target(seg_mask,rpn_features,gt_boxes3d,rpn_xyz,pts_depth,roi_boxes3d,rpn_intensity): + seg_mask = np.array(seg_mask) + features = np.array(rpn_features) + gt_boxes3d = np.array(gt_boxes3d) + rpn_xyz = np.array(rpn_xyz) + pts_depth = np.array(pts_depth) + roi_boxes3d = np.array(roi_boxes3d) + rpn_intensity = np.array(rpn_intensity) + batch_rois, batch_gt_of_rois, batch_roi_iou = sample_rois_for_rcnn(roi_boxes3d, gt_boxes3d) + + if cfg.RCNN.USE_INTENSITY: + pts_extra_input_list = [np.expand_dims(rpn_intensity, axis=2), + np.expand_dims(seg_mask, axis=2)] + else: + pts_extra_input_list = [np.expand_dims(seg_mask, axis=2)] + + if cfg.RCNN.USE_DEPTH: + pts_depth = pts_depth / 70.0 - 0.5 + pts_extra_input_list.append(np.expand_dims(pts_depth, axis=2)) + pts_extra_input = np.concatenate(pts_extra_input_list, axis=2) + + # point cloud pooling + pts_feature = np.concatenate((pts_extra_input, rpn_features), axis=2) + + batch_rois = batch_rois.astype(np.float32) + + pooled_features, pooled_empty_flag = roipool3d_utils.roipool3d_gpu( + rpn_xyz, pts_feature, batch_rois, cfg.RCNN.POOL_EXTRA_WIDTH, + sampled_pt_num=cfg.RCNN.NUM_POINTS + ) + + sampled_pts, sampled_features = pooled_features[:, :, :, 0:3], pooled_features[:, :, :, 3:] + # data augmentation + if cfg.AUG_DATA: + # data augmentation + sampled_pts, batch_rois, batch_gt_of_rois = \ + data_augmentation(sampled_pts, batch_rois, batch_gt_of_rois) + + # canonical transformation + batch_size = batch_rois.shape[0] + roi_ry = batch_rois[:, :, 6] % (2 * np.pi) + roi_center = batch_rois[:, :, 0:3] + sampled_pts = sampled_pts - np.expand_dims(roi_center, axis=2) # (B, M, 512, 3) + batch_gt_of_rois[:, :, 0:3] = batch_gt_of_rois[:, :, 0:3] - roi_center + batch_gt_of_rois[:, :, 6] = batch_gt_of_rois[:, :, 6] - roi_ry + + for k in range(batch_size): + sampled_pts[k] = kitti_utils.rotate_pc_along_y_np(sampled_pts[k], batch_rois[k, :, 6]) + batch_gt_of_rois[k] = np.squeeze(kitti_utils.rotate_pc_along_y_np( + np.expand_dims(batch_gt_of_rois[k], axis=1), roi_ry[k]), axis=1) + + # regression valid mask + valid_mask = (pooled_empty_flag == 0) + reg_valid_mask = ((batch_roi_iou > cfg.RCNN.REG_FG_THRESH) & valid_mask).astype(np.float32) + + # classification label + batch_cls_label = (batch_roi_iou > cfg.RCNN.CLS_FG_THRESH).astype(np.int64) + invalid_mask = (batch_roi_iou > cfg.RCNN.CLS_BG_THRESH) & (batch_roi_iou < cfg.RCNN.CLS_FG_THRESH) + batch_cls_label[valid_mask == 0] = -1 + batch_cls_label[invalid_mask > 0] = -1 + + output_dict = {'sampled_pts': sampled_pts.reshape(-1, cfg.RCNN.NUM_POINTS, 3).astype(np.float32), + 'pts_feature': sampled_features.reshape(-1, cfg.RCNN.NUM_POINTS, sampled_features.shape[3]).astype(np.float32), + 'cls_label': batch_cls_label.reshape(-1), + 'reg_valid_mask': reg_valid_mask.reshape(-1).astype(np.float32), + 'gt_of_rois': batch_gt_of_rois.reshape(-1, 7).astype(np.float32), + 'gt_iou': batch_roi_iou.reshape(-1).astype(np.float32), + 'roi_boxes3d': batch_rois.reshape(-1, 7).astype(np.float32)} + + return output_dict.values() + + return generate_proposal_target + + +if __name__ == "__main__": + + input_dict = {} + input_dict['roi_boxes3d'] = np.load("models/rpn_data/roi_boxes3d.npy") + input_dict['gt_boxes3d'] = np.load("models/rpn_data/gt_boxes3d.npy") + input_dict['rpn_xyz'] = np.load("models/rpn_data/rpn_xyz.npy") + input_dict['rpn_features'] = np.load("models/rpn_data/rpn_features.npy") + input_dict['rpn_intensity'] = np.load("models/rpn_data/rpn_intensity.npy") + input_dict['seg_mask'] = np.load("models/rpn_data/seg_mask.npy") + input_dict['pts_depth'] = np.load("models/rpn_data/pts_depth.npy") + for k, v in input_dict.items(): + print(k, v.shape, np.sum(np.abs(v))) + input_dict[k] = np.expand_dims(v, axis=0) + + from utils.config import cfg + cfg.RPN.LOC_XZ_FINE = True + cfg.TEST.RPN_DISTANCE_BASED_PROPOSE = False + cfg.RPN.NMS_TYPE = 'rotate' + + proposal_target_func = get_proposal_target_func(cfg) + out_dict = proposal_target_func(input_dict['seg_mask'],input_dict['rpn_features'],input_dict['gt_boxes3d'], + input_dict['rpn_xyz'],input_dict['pts_depth'],input_dict['roi_boxes3d'],input_dict['rpn_intensity']) + for key in out_dict.keys(): + print("name:{}, shape{}".format(key,out_dict[key].shape)) diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/proposal_utils.py b/PaddleCV/Paddle3D/PointRCNN/utils/proposal_utils.py new file mode 100644 index 00000000..9160ffe8 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/proposal_utils.py @@ -0,0 +1,270 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +#Licensed under the Apache License, Version 2.0 (the "License"); +#you may not use this file except in compliance with the License. +#You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +#Unless required by applicable law or agreed to in writing, software +#distributed under the License is distributed on an "AS IS" BASIS, +#WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#See the License for the specific language governing permissions and +#limitations under the License. +""" +Contains proposal functions +""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import numpy as np +import paddle.fluid as fluid + +import utils.box_utils as box_utils +from utils.config import cfg + +__all__ = ["get_proposal_func"] + + +def get_proposal_func(cfg, mode='TRAIN'): + def decode_bbox_target(roi_box3d, pred_reg, anchor_size, loc_scope, + loc_bin_size, num_head_bin, get_xz_fine=True, + loc_y_scope=0.5, loc_y_bin_size=0.25, + get_y_by_bin=False, get_ry_fine=False): + per_loc_bin_num = int(loc_scope / loc_bin_size) * 2 + loc_y_bin_num = int(loc_y_scope / loc_y_bin_size) * 2 + + # recover xz localization + x_bin_l, x_bin_r = 0, per_loc_bin_num + z_bin_l, z_bin_r = per_loc_bin_num, per_loc_bin_num * 2 + start_offset = z_bin_r + + x_bin = np.argmax(pred_reg[:, x_bin_l: x_bin_r], axis=1) + z_bin = np.argmax(pred_reg[:, z_bin_l: z_bin_r], axis=1) + + pos_x = x_bin.astype('float32') * loc_bin_size + loc_bin_size / 2 - loc_scope + pos_z = z_bin.astype('float32') * loc_bin_size + loc_bin_size / 2 - loc_scope + if get_xz_fine: + x_res_l, x_res_r = per_loc_bin_num * 2, per_loc_bin_num * 3 + z_res_l, z_res_r = per_loc_bin_num * 3, per_loc_bin_num * 4 + start_offset = z_res_r + + x_res_norm = pred_reg[:, x_res_l:x_res_r][np.arange(len(x_bin)), x_bin] + z_res_norm = pred_reg[:, z_res_l:z_res_r][np.arange(len(z_bin)), z_bin] + + x_res = x_res_norm * loc_bin_size + z_res = z_res_norm * loc_bin_size + pos_x += x_res + pos_z += z_res + + # recover y localization + if get_y_by_bin: + y_bin_l, y_bin_r = start_offset, start_offset + loc_y_bin_num + y_res_l, y_res_r = y_bin_r, y_bin_r + loc_y_bin_num + start_offset = y_res_r + + y_bin = np.argmax(pred_reg[:, y_bin_l: y_bin_r], axis=1) + y_res_norm = pred_reg[:, y_res_l:y_res_r][np.arange(len(y_bin)), y_bin] + y_res = y_res_norm * loc_y_bin_size + pos_y = y_bin.astype('float32') * loc_y_bin_size + loc_y_bin_size / 2 - loc_y_scope + y_res + pos_y = pos_y + np.array(roi_box3d[:, 1]).reshape(-1) + else: + y_offset_l, y_offset_r = start_offset, start_offset + 1 + start_offset = y_offset_r + + pos_y = np.array(roi_box3d[:, 1]) + np.array(pred_reg[:, y_offset_l]) + pos_y = pos_y.reshape(-1) + + # recover ry rotation + ry_bin_l, ry_bin_r = start_offset, start_offset + num_head_bin + ry_res_l, ry_res_r = ry_bin_r, ry_bin_r + num_head_bin + + ry_bin = np.argmax(pred_reg[:, ry_bin_l: ry_bin_r], axis=1) + ry_res_norm = pred_reg[:, ry_res_l:ry_res_r][np.arange(len(ry_bin)), ry_bin] + if get_ry_fine: + # divide pi/2 into several bins + angle_per_class = (np.pi / 2) / num_head_bin + ry_res = ry_res_norm * (angle_per_class / 2) + ry = (ry_bin.astype('float32') * angle_per_class + angle_per_class / 2) + ry_res - np.pi / 4 + else: + angle_per_class = (2 * np.pi) / num_head_bin + ry_res = ry_res_norm * (angle_per_class / 2) + + # bin_center is (0, 30, 60, 90, 120, ..., 270, 300, 330) + ry = np.fmod(ry_bin.astype('float32') * angle_per_class + ry_res, 2 * np.pi) + ry[ry > np.pi] -= 2 * np.pi + + # recover size + size_res_l, size_res_r = ry_res_r, ry_res_r + 3 + assert size_res_r == pred_reg.shape[1] + + size_res_norm = pred_reg[:, size_res_l: size_res_r] + hwl = size_res_norm * anchor_size + anchor_size + + def rotate_pc_along_y(pc, angle): + cosa = np.cos(angle).reshape(-1, 1) + sina = np.sin(angle).reshape(-1, 1) + + R = np.concatenate([cosa, -sina, sina, cosa], axis=-1).reshape(-1, 2, 2) + pc_temp = pc[:, [0, 2]].reshape(-1, 1, 2) + pc[:, [0, 2]] = np.matmul(pc_temp, R.transpose(0, 2, 1)).reshape(-1, 2) + + return pc + + # shift to original coords + roi_center = np.array(roi_box3d[:, 0:3]) + shift_ret_box3d = np.concatenate(( + pos_x.reshape(-1, 1), + pos_y.reshape(-1, 1), + pos_z.reshape(-1, 1), + hwl, ry.reshape(-1, 1)), axis=1) + ret_box3d = shift_ret_box3d + if roi_box3d.shape[1] == 7: + roi_ry = np.array(roi_box3d[:, 6]).reshape(-1) + ret_box3d = rotate_pc_along_y(np.array(shift_ret_box3d), -roi_ry) + ret_box3d[:, 6] += roi_ry + ret_box3d[:, [0, 2]] += roi_center[:, [0, 2]] + return ret_box3d + + def distance_based_proposal(scores, proposals, sorted_idxs): + nms_range_list = [0, 40.0, 80.0] + pre_tot_top_n = cfg[mode].RPN_PRE_NMS_TOP_N + pre_top_n_list = [0, int(pre_tot_top_n * 0.7), pre_tot_top_n - int(pre_tot_top_n * 0.7)] + post_tot_top_n = cfg[mode].RPN_POST_NMS_TOP_N + post_top_n_list = [0, int(post_tot_top_n * 0.7), post_tot_top_n - int(post_tot_top_n * 0.7)] + + batch_size = scores.shape[0] + ret_proposals = np.zeros((batch_size, cfg[mode].RPN_POST_NMS_TOP_N, 7), dtype='float32') + ret_scores= np.zeros((batch_size, cfg[mode].RPN_POST_NMS_TOP_N, 1), dtype='float32') + + for b, (score, proposal, sorted_idx) in enumerate(zip(scores, proposals, sorted_idxs)): + # sort by score + score_ord = score[sorted_idx] + proposal_ord = proposal[sorted_idx] + + dist = proposal_ord[:, 2] + first_mask = (dist > nms_range_list[0]) & (dist <= nms_range_list[1]) + + scores_single_list, proposals_single_list = [], [] + for i in range(1, len(nms_range_list)): + # get proposal distance mask + dist_mask = ((dist > nms_range_list[i - 1]) & (dist <= nms_range_list[i])) + + if dist_mask.sum() != 0: + # this area has points, reduce by mask + cur_scores = score_ord[dist_mask] + cur_proposals = proposal_ord[dist_mask] + + # fetch pre nms top K + cur_scores = cur_scores[:pre_top_n_list[i]] + cur_proposals = cur_proposals[:pre_top_n_list[i]] + else: + assert i == 2, '%d' % i + # this area doesn't have any points, so use rois of first area + cur_scores = score_ord[first_mask] + cur_proposals = proposal_ord[first_mask] + + # fetch top K of first area + cur_scores = cur_scores[pre_top_n_list[i - 1]:][:pre_top_n_list[i]] + cur_proposals = cur_proposals[pre_top_n_list[i - 1]:][:pre_top_n_list[i]] + + # oriented nms + boxes_bev = box_utils.boxes3d_to_bev(cur_proposals) + s_scores, s_proposals = box_utils.box_nms( + boxes_bev, cur_scores, cur_proposals, + cfg[mode].RPN_NMS_THRESH, post_top_n_list[i], + cfg.RPN.NMS_TYPE) + if len(s_scores) > 0: + scores_single_list.append(s_scores) + proposals_single_list.append(s_proposals) + + scores_single = np.concatenate(scores_single_list, axis=0) + proposals_single = np.concatenate(proposals_single_list, axis=0) + + prop_num = proposals_single.shape[0] + ret_scores[b, :prop_num, 0] = scores_single + ret_proposals[b, :prop_num] = proposals_single + # ret_proposals.tofile("proposal.data") + # ret_scores.tofile("score.data") + return np.concatenate([ret_proposals, ret_scores], axis=-1) + + def score_based_proposal(scores, proposals, sorted_idxs): + batch_size = scores.shape[0] + ret_proposals = np.zeros((batch_size, cfg[mode].RPN_POST_NMS_TOP_N, 7), dtype='float32') + ret_scores= np.zeros((batch_size, cfg[mode].RPN_POST_NMS_TOP_N, 1), dtype='float32') + for b, (score, proposal, sorted_idx) in enumerate(zip(scores, proposals, sorted_idxs)): + # sort by score + score_ord = score[sorted_idx] + proposal_ord = proposal[sorted_idx] + + # pre nms top K + cur_scores = score_ord[:cfg[mode].RPN_PRE_NMS_TOP_N] + cur_proposals = proposal_ord[:cfg[mode].RPN_PRE_NMS_TOP_N] + + boxes_bev = box_utils.boxes3d_to_bev(cur_proposals) + s_scores, s_proposals = box_utils.box_nms( + boxes_bev, cur_scores, cur_proposals, + cfg[mode].RPN_NMS_THRESH, + cfg[mode].RPN_POST_NMS_TOP_N, + 'rotate') + prop_num = len(s_proposals) + ret_scores[b, :prop_num, 0] = s_scores + ret_proposals[b, :prop_num] = s_proposals + # ret_proposals.tofile("proposal.data") + # ret_scores.tofile("score.data") + return np.concatenate([ret_proposals, ret_scores], axis=-1) + + def generate_proposal(x): + rpn_scores = np.array(x[:, :, 0])[:, :, 0] + roi_box3d = x[:, :, 1:4] + pred_reg = x[:, :, 4:] + + proposals = decode_bbox_target( + np.array(roi_box3d).reshape(-1, roi_box3d.shape()[-1]), + np.array(pred_reg).reshape(-1, pred_reg.shape()[-1]), + anchor_size=np.array(cfg.CLS_MEAN_SIZE[0], dtype='float32'), + loc_scope=cfg.RPN.LOC_SCOPE, + loc_bin_size=cfg.RPN.LOC_BIN_SIZE, + num_head_bin=cfg.RPN.NUM_HEAD_BIN, + get_xz_fine=cfg.RPN.LOC_XZ_FINE, + get_y_by_bin=False, + get_ry_fine=False) + proposals[:, 1] += proposals[:, 3] / 2 + proposals = proposals.reshape(rpn_scores.shape[0], -1, proposals.shape[-1]) + + sorted_idxs = np.argsort(-rpn_scores, axis=-1) + + if cfg.TEST.RPN_DISTANCE_BASED_PROPOSE: + ret = distance_based_proposal(rpn_scores, proposals, sorted_idxs) + else: + ret = score_based_proposal(rpn_scores, proposals, sorted_idxs) + + return ret + + + return generate_proposal + + +if __name__ == "__main__": + np.random.seed(3333) + x_np = np.random.random((4, 256, 84)).astype('float32') + + from config import cfg + cfg.RPN.LOC_XZ_FINE = True + # cfg.TEST.RPN_DISTANCE_BASED_PROPOSE = False + # cfg.RPN.NMS_TYPE = 'rotate' + proposal_func = get_proposal_func(cfg) + + x = fluid.layers.data(name="x", shape=[256, 84], dtype='float32') + proposal = fluid.default_main_program().current_block().create_var( + name="proposal", dtype='float32', shape=[256, 7]) + fluid.layers.py_func(proposal_func, x, proposal) + loss = fluid.layers.reduce_mean(proposal) + + place = fluid.CUDAPlace(0) + exe = fluid.Executor(place) + exe.run(fluid.default_startup_program()) + ret = exe.run(fetch_list=[proposal.name, loss.name], feed={'x': x_np}) + print(ret) diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/pts_utils/CMakeLists.txt b/PaddleCV/Paddle3D/PointRCNN/utils/pts_utils/CMakeLists.txt new file mode 100644 index 00000000..044bbed5 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/pts_utils/CMakeLists.txt @@ -0,0 +1,6 @@ + +cmake_minimum_required(VERSION 2.8.12) +project(pts_utils) + +add_subdirectory(pybind11) +pybind11_add_module(pts_utils pts_utils.cpp) diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/pts_utils/pts_utils.cpp b/PaddleCV/Paddle3D/PointRCNN/utils/pts_utils/pts_utils.cpp new file mode 100644 index 00000000..356b02ba --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/pts_utils/pts_utils.cpp @@ -0,0 +1,62 @@ +#include +#include +#include + +namespace py = pybind11; + +int pt_in_box3d(float x, float y, float z, float cx, float cy, float cz, float h, float w, float l, float cosa, float sina) { + if ((fabsf(x - cx) > 10.) || (fabsf(y - cy) > h / 2.0) || (fabsf(z - cz) > 10.)){ + return 0; + } + + float x_rot = (x - cx) * cosa + (z - cz) * (-sina); + float z_rot = (x - cx) * sina + (z - cz) * cosa; + + int in_flag = static_cast((x_rot >= -l / 2.0) & (x_rot <= l / 2.0) & (z_rot >= -w / 2.0) & (z_rot <= w / 2.0)); + return in_flag; +} + +py::array_t pts_in_boxes3d(py::array_t pts, py::array_t boxes) { + py::buffer_info pts_buf= pts.request(), boxes_buf = boxes.request(); + + if (pts_buf.ndim != 2 || boxes_buf.ndim != 2) { + throw std::runtime_error("Number of dimensions must be 2"); + } + if (pts_buf.shape[1] != 3) { + throw std::runtime_error("pts 2nd dimension must be 3"); + } + if (boxes_buf.shape[1] != 7) { + throw std::runtime_error("boxes 2nd dimension must be 7"); + } + + auto pts_num = pts_buf.shape[0]; + auto boxes_num = boxes_buf.shape[0]; + auto mask = py::array_t(pts_num * boxes_num); + py::buffer_info mask_buf = mask.request(); + + float *pts_ptr = (float *) pts_buf.ptr, + *boxes_ptr = (float *) boxes_buf.ptr; + int *mask_ptr = (int *) mask_buf.ptr; + + for (ssize_t i = 0; i < boxes_num; i++) { + float cx = boxes_ptr[i * 7]; + float cy = boxes_ptr[i * 7 + 1] - boxes_ptr[i * 7 + 3] / 2.; + float cz = boxes_ptr[i * 7 + 2]; + float h = boxes_ptr[i * 7 + 3]; + float w = boxes_ptr[i * 7 + 4]; + float l = boxes_ptr[i * 7 + 5]; + float angle = boxes_ptr[i * 7 + 6]; + float cosa = cosf(angle); + float sina = sinf(angle); + for (ssize_t j = 0; j < pts_num; j++) { + mask_ptr[i * pts_num + j] = pt_in_box3d(pts_ptr[j * 3], pts_ptr[j * 3 + 1], pts_ptr[j * 3 + 2], cx, cy, cz, h, w, l, cosa, sina); + } + } + + mask.resize({boxes_num, pts_num}); + return mask; +} + +PYBIND11_MODULE(pts_utils, m) { + m.def("pts_in_boxes3d", &pts_in_boxes3d, "Calculate mask for whether points in boxes3d"); +} diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/pts_utils/setup.py b/PaddleCV/Paddle3D/PointRCNN/utils/pts_utils/setup.py new file mode 100644 index 00000000..e44e80ea --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/pts_utils/setup.py @@ -0,0 +1,12 @@ +from setuptools import setup +from setuptools import Extension + +setup( + name='pts_utils', + ext_modules = [Extension( + name='pts_utils', + sources=['pts_utils.cpp'], + include_dirs=[r'../../pybind11/include'], + extra_compile_args=['-std=c++11'] + )], +) diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/pts_utils/test.py b/PaddleCV/Paddle3D/PointRCNN/utils/pts_utils/test.py new file mode 100644 index 00000000..e4e3be28 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/pts_utils/test.py @@ -0,0 +1,7 @@ +import numpy as np +import pts_utils + +a = np.random.random((16384, 3)).astype('float32') +b = np.random.random((64, 7)).astype('float32') +c = pts_utils.pts_in_boxes3d(a, b) +print(a, b, c, c.shape, np.sum(c)) diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/run_utils.py b/PaddleCV/Paddle3D/PointRCNN/utils/run_utils.py new file mode 100644 index 00000000..0df37e56 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/run_utils.py @@ -0,0 +1,110 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +#Licensed under the Apache License, Version 2.0 (the "License"); +#you may not use this file except in compliance with the License. +#You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +#Unless required by applicable law or agreed to in writing, software +#distributed under the License is distributed on an "AS IS" BASIS, +#WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#See the License for the specific language governing permissions and +#limitations under the License. +""" +Contains common utility functions. +""" + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import sys +import six +import logging +import numpy as np +import paddle.fluid as fluid + +__all__ = ["check_gpu", "print_arguments", "parse_outputs", "Stat"] + +logger = logging.getLogger(__name__) + + +def check_gpu(use_gpu): + """ + Log error and exit when set use_gpu=True in paddlepaddle + cpu version. + """ + err = "Config use_gpu cannot be set as True while you are " \ + "using paddlepaddle cpu version ! \nPlease try: \n" \ + "\t1. Install paddlepaddle-gpu to run model on GPU \n" \ + "\t2. Set --use_gpu=False to run model on CPU" + + try: + if use_gpu and not fluid.is_compiled_with_cuda(): + logger.error(err) + sys.exit(1) + except Exception as e: + pass + + +def print_arguments(args): + """Print argparse's arguments. + + Usage: + + .. code-block:: python + + parser = argparse.ArgumentParser() + parser.add_argument("name", default="Jonh", type=str, help="User name.") + args = parser.parse_args() + print_arguments(args) + + :param args: Input argparse.Namespace for printing. + :type args: argparse.Namespace + """ + logger.info("----------- Configuration Arguments -----------") + for arg, value in sorted(six.iteritems(vars(args))): + logger.info("%s: %s" % (arg, value)) + logger.info("------------------------------------------------") + + +def parse_outputs(outputs, filter_key=None, extra_keys=None, prog=None): + keys, values = [], [] + for k, v in outputs.items(): + if filter_key is not None and k.find(filter_key) < 0: + continue + keys.append(k) + v.persistable = True + values.append(v.name) + + if prog is not None and extra_keys is not None: + for k in extra_keys: + try: + v = fluid.framework._get_var(k, prog) + keys.append(k) + v.persistable = True + values.append(v.name) + except: + pass + return keys, values + + +class Stat(object): + def __init__(self): + self.stats = {} + + def update(self, keys, values): + for k, v in zip(keys, values): + if k not in self.stats: + self.stats[k] = [] + self.stats[k].append(v) + + def reset(self): + self.stats = {} + + def get_mean_log(self): + log = "" + for k, v in self.stats.items(): + log += "avg_{}: {:.4f}, ".format(k, np.mean(v)) + return log diff --git a/PaddleCV/Paddle3D/PointRCNN/utils/save_utils.py b/PaddleCV/Paddle3D/PointRCNN/utils/save_utils.py new file mode 100644 index 00000000..c24a89a2 --- /dev/null +++ b/PaddleCV/Paddle3D/PointRCNN/utils/save_utils.py @@ -0,0 +1,132 @@ +# Copyright (c) 2019 PaddlePaddle Authors. All Rights Reserve. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import numpy as np +from utils.config import cfg +from utils import calibration as calib +import utils.cyops.kitti_utils as kitti_utils + +__all__ = ['save_rpn_feature', 'save_kitti_result', 'save_kitti_format'] + + +def save_rpn_feature(rets, kitti_features_dir): + """ + save rpn features for RCNN offline training + """ + + sample_id = rets['sample_id'][0] + backbone_xyz = rets['backbone_xyz'][0] + backbone_feature = rets['backbone_feature'][0] + pts_features = rets['pts_features'][0] + seg_mask = rets['seg_mask'][0] + rpn_cls = rets['rpn_cls'][0] + + for i in range(len(sample_id)): + pts_intensity = pts_features[i, :, 0] + s_id = sample_id[i, 0] + + output_file = os.path.join(kitti_features_dir, '%06d.npy' % s_id) + xyz_file = os.path.join(kitti_features_dir, '%06d_xyz.npy' % s_id) + seg_file = os.path.join(kitti_features_dir, '%06d_seg.npy' % s_id) + intensity_file = os.path.join( + kitti_features_dir, '%06d_intensity.npy' % s_id) + np.save(output_file, backbone_feature[i]) + np.save(xyz_file, backbone_xyz[i]) + np.save(seg_file, seg_mask[i]) + np.save(intensity_file, pts_intensity) + rpn_scores_raw_file = os.path.join( + kitti_features_dir, '%06d_rawscore.npy' % s_id) + np.save(rpn_scores_raw_file, rpn_cls[i]) + + +def save_kitti_result(rets, seg_output_dir, kitti_output_dir, reader, classes): + sample_id = rets['sample_id'][0] + roi_scores_row = rets['roi_scores_row'][0] + bboxes3d = rets['rois'][0] + pts_rect = rets['pts_rect'][0] + seg_mask = rets['seg_mask'][0] + rpn_cls_label = rets['rpn_cls_label'][0] + gt_boxes3d = rets['gt_boxes3d'][0] + gt_boxes3d_num = rets['gt_boxes3d'][1] + + for i in range(len(sample_id)): + s_id = sample_id[i, 0] + + seg_result_data = np.concatenate((pts_rect[i].reshape(-1, 3), + rpn_cls_label[i].reshape(-1, 1), + seg_mask[i].reshape(-1, 1)), + axis=1).astype('float16') + seg_output_file = os.path.join(seg_output_dir, '%06d.npy' % s_id) + np.save(seg_output_file, seg_result_data) + + scores = roi_scores_row[i, :] + bbox3d = bboxes3d[i, :] + img_shape = reader.get_image_shape(s_id) + calib = reader.get_calib(s_id) + + corners3d = kitti_utils.boxes3d_to_corners3d(bbox3d) + img_boxes, _ = calib.corners3d_to_img_boxes(corners3d) + + img_boxes[:, 0] = np.clip(img_boxes[:, 0], 0, img_shape[1] - 1) + img_boxes[:, 1] = np.clip(img_boxes[:, 1], 0, img_shape[0] - 1) + img_boxes[:, 2] = np.clip(img_boxes[:, 2], 0, img_shape[1] - 1) + img_boxes[:, 3] = np.clip(img_boxes[:, 3], 0, img_shape[0] - 1) + + img_boxes_w = img_boxes[:, 2] - img_boxes[:, 0] + img_boxes_h = img_boxes[:, 3] - img_boxes[:, 1] + box_valid_mask = np.logical_and( + img_boxes_w < img_shape[1] * 0.8, img_boxes_h < img_shape[0] * 0.8) + + kitti_output_file = os.path.join(kitti_output_dir, '%06d.txt' % s_id) + with open(kitti_output_file, 'w') as f: + for k in range(bbox3d.shape[0]): + if box_valid_mask[k] == 0: + continue + x, z, ry = bbox3d[k, 0], bbox3d[k, 2], bbox3d[k, 6] + beta = np.arctan2(z, x) + alpha = -np.sign(beta) * np.pi / 2 + beta + ry + + f.write('{} -1 -1 {:.4f} {:.4f} {:.4f} {:.4f} {:.4f} {:.4f} {:.4f} {:.4f} {:.4f} {:.4f} {:.4f} {:.4f} {:.4f}\n'.format( + classes, alpha, img_boxes[k, 0], img_boxes[k, 1], img_boxes[k, 2], img_boxes[k, 3], + bbox3d[k, 3], bbox3d[k, 4], bbox3d[k, 5], bbox3d[k, 0], bbox3d[k, 1], bbox3d[k, 2], + bbox3d[k, 6], scores[k])) + + +def save_kitti_format(sample_id, calib, bbox3d, kitti_output_dir, scores, img_shape): + corners3d = kitti_utils.boxes3d_to_corners3d(bbox3d) + img_boxes, _ = calib.corners3d_to_img_boxes(corners3d) + img_boxes[:, 0] = np.clip(img_boxes[:, 0], 0, img_shape[1] - 1) + img_boxes[:, 1] = np.clip(img_boxes[:, 1], 0, img_shape[0] - 1) + img_boxes[:, 2] = np.clip(img_boxes[:, 2], 0, img_shape[1] - 1) + img_boxes[:, 3] = np.clip(img_boxes[:, 3], 0, img_shape[0] - 1) + + img_boxes_w = img_boxes[:, 2] - img_boxes[:, 0] + img_boxes_h = img_boxes[:, 3] - img_boxes[:, 1] + box_valid_mask = np.logical_and(img_boxes_w < img_shape[1] * 0.8, img_boxes_h < img_shape[0] * 0.8) + + kitti_output_file = os.path.join(kitti_output_dir, '%06d.txt' % sample_id) + with open(kitti_output_file, 'w') as f: + for k in range(bbox3d.shape[0]): + if box_valid_mask[k] == 0: + continue + x, z, ry = bbox3d[k, 0], bbox3d[k, 2], bbox3d[k, 6] + beta = np.arctan2(z, x) + alpha = -np.sign(beta) * np.pi / 2 + beta + ry + + f.write('%s -1 -1 %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f\n' % + (cfg.CLASSES, alpha, img_boxes[k, 0], img_boxes[k, 1], img_boxes[k, 2], img_boxes[k, 3], + bbox3d[k, 3], bbox3d[k, 4], bbox3d[k, 5], bbox3d[k, 0], bbox3d[k, 1], bbox3d[k, 2], + bbox3d[k, 6], scores[k])) + -- GitLab