Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 88a3b52

Browse files
committedJan 20, 2022
Add evaluation instructions and helpers
- code used to create the simplified KITTI-style annotations - Dockerfile that can be used for running evaluation - example script that prepares the provided detection for evaluation - util for converting object rotation between coordinate systems
1 parent 4e1ceae commit 88a3b52

5 files changed

+348
-1
lines changed
 

‎calibration.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from typing import Type
77

88
import numpy as np
9+
from pyquaternion import Quaternion
10+
911
import constants
1012

1113

@@ -158,7 +160,7 @@ def invert_3d_transform(transform):
158160

159161

160162
def get_3d_transform_camera_lidar(calib: dict):
161-
"""Get 3D transformation between lidar and camera."""
163+
"""Get 3D transformation from lidar to camera."""
162164
t_refframe_to_frame = calib[constants.LIDAR_EXTRINSICS]
163165
t_refframe_from_frame = calib[constants.EXTRINSICS]
164166

@@ -168,6 +170,11 @@ def get_3d_transform_camera_lidar(calib: dict):
168170
return t_from_frame_to_frame
169171

170172

173+
def transform_rotation(rotation: Quaternion, transform: np.ndarray):
174+
"""Transform the rotation between two frames defined by the transformation."""
175+
return Quaternion(matrix=transform[:3, :3].T) * rotation
176+
177+
171178
class CameraInfo(ABC):
172179
"""Class to handle camera info."""
173180

‎eval/Dockerfile

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
FROM ubuntu
2+
3+
ENV TZ=Europe/Stockholm DEBIAN_FRONTEND=noninteractive
4+
RUN apt-get update && apt-get install -y build-essential libboost-all-dev cmake git
5+
6+
RUN cd /root && git clone https://github.com/zenseact/kitti_native_evaluation.git
7+
8+
WORKDIR /root/kitti_native_evaluation
9+
10+
RUN cmake ./ -DCMAKE_CXX_FLAGS="-O3" && make

‎eval/README.md

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Evaluation example
2+
3+
Here is a simple example of how to use the evaluation program.
4+
5+
For convenience we have provided a `kitti-eval` docker image. If for some reason it is not available you can easily rebuild it with:
6+
7+
```docker build -f Dockerfile -t kitti-eval .```
8+
9+
Then, you can run an evaluation using the image with the following command:
10+
11+
```docker run -v /gt-dir:/gt-dir -v /det-dir:/det-dir kitti-eval ./evaluate_object_3d_offline /gt-dir /det-dir```
12+
13+
For an example of how the pseudo-annoation files should look you can see the prepare_eval.py. It takes our simple detections, extracts the relevant frames, and prepares them for use in the evaluation script.
14+
For the LIDAR detections that includes converting them to the camera coordinate system as well as adjusting the (nonexistent) 2d box to pass the minimum height requirement.
15+
Note that this is overly simplified and the projected 3d boxes could have a lower height and should thus actually be ignored.
16+
17+
Full flow (separate eval for camera 2d detections and lidar 3d detections):
18+
1. ```python prepare_eval.py --tmp-det-dir=/tmp/det-dir```
19+
2. ```chmod -R 777 /tmp/det-dir```
20+
3. ```docker run -v /mnt/ai_sweden/road_data_lab/zenseact_disk/:/gt-dir -v /tmp/det-dir:/det-dir kitti-eval ./evaluate_object_3d_offline /gt-dir /det-dir/camera```
21+
4. ```docker run -v /mnt/ai_sweden/road_data_lab/zenseact_disk/:/gt-dir -v /tmp/det-dir:/det-dir kitti-eval ./evaluate_object_3d_offline /gt-dir /det-dir/lidar```
22+
23+
24+
## Results of evaluating provided 2d detections
25+
```
26+
vehicle_detection_AP : 74.971565
27+
pedestrian_detection_AP : 52.170540
28+
cyclist_detection_AP : 38.734921
29+
```
30+
31+
## Results of evaluating provided 3d detections
32+
```
33+
vehicle_detection_BEV_AP : 72.898407
34+
pedestrian_detection_BEV_AP : 48.200329
35+
cyclist_detection_BEV_AP : 40.517864
36+
vehicle_detection_3D_AP : 61.186440
37+
pedestrian_detection_3D_AP : 41.484516
38+
cyclist_detection_3D_AP : 37.284695
39+
```

‎eval/convert_annotations_to_kitti.py

+169
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""Converts dynamic object annotations to KITTI format."""
2+
import argparse
3+
import glob
4+
import json
5+
import os
6+
from datetime import datetime
7+
from os.path import join, basename
8+
from typing import List, Callable
9+
10+
import numpy as np
11+
from pyquaternion import Quaternion
12+
from tqdm import tqdm
13+
14+
from calibration import (
15+
load_calib_from_json,
16+
get_3d_transform_camera_lidar,
17+
rigid_transform_3d,
18+
transform_rotation,
19+
)
20+
from constants import TIME_FORMAT, SIZE, LOCATION, ROTATION
21+
from plot_objects_on_image import ObjectAnnotationHandler
22+
23+
IMAGE_DIMS = np.array([3848, 2168]) # width, height
24+
25+
OCCLUSION_MAP = {
26+
"None": 0,
27+
"Light": 1,
28+
"Medium": 1,
29+
"Heavy": 2,
30+
"VeryHeavy": 2,
31+
"Undefined": 2, # If undefined we assume the worst
32+
}
33+
34+
35+
def _parse_class(obj_properties):
36+
obj_cls = obj_properties["class"]
37+
if obj_cls not in ("VulnerableVehicle", "Vehicle", "Pedestrian"):
38+
# Remove Animals, Debris, Movers and any other unwanted classes
39+
return None
40+
elif obj_properties["unclear"] or obj_properties["object_type"] == "Inconclusive":
41+
# Ignore unclear and inconclusive objects
42+
obj_cls = "DontCare"
43+
elif obj_cls == "VulnerableVehicle":
44+
# Rename the VulnerableVehicle class to Cyclist to match KITTI
45+
obj_cls = "Cyclist"
46+
# Remove stuff without rider
47+
if obj_properties.get("with_rider", "True") == "False":
48+
return None
49+
# Ignore everything that's not a bicyclist or motorbicyclist
50+
elif obj_properties["object_type"] not in ("Bicycle", "Motorcycle"):
51+
obj_cls = "DontCare"
52+
elif obj_cls == "Vehicle":
53+
# Ignore more exotic vehicle classes (HeavyEquip, TramTrain, Other)
54+
if obj_properties["object_type"] not in ("Car", "Van", "Truck", "Trailer", "Bus"):
55+
obj_cls = "DontCare"
56+
elif obj_cls == "Pedestrian":
57+
# No special treatment for pedestrians
58+
pass
59+
return obj_cls
60+
61+
62+
def _convert_to_kitti(
63+
objects: List[ObjectAnnotationHandler], yaw_func: Callable[[Quaternion], float]
64+
) -> List[str]:
65+
kitti_annotation_lines = []
66+
for obj in objects:
67+
class_name = _parse_class(obj.properties)
68+
if class_name is None:
69+
continue # discard object
70+
truncation, xmax, xmin, ymax, ymin = _parse_bbox_2d(obj.outer_points)
71+
if obj.marking3d is None:
72+
size, location, yaw, alpha = [0, 0, 0], [0, 0, 0], 0, 0
73+
else:
74+
size = obj.marking3d[SIZE][::-1] # H,W,L not L,W,H
75+
location = obj.marking3d[LOCATION] # x,y,z
76+
yaw = yaw_func(obj.marking3d[ROTATION])
77+
alpha = 0 # TODO: calculate this!
78+
if class_name != "DontCare" and "occlusion_ratio" not in obj.properties:
79+
print("Missing occlusion for obj: ", obj)
80+
kitti_obj = " ".join(
81+
map(
82+
str,
83+
[
84+
class_name,
85+
truncation,
86+
OCCLUSION_MAP[obj.properties.get("occlusion_ratio", "Undefined")],
87+
alpha,
88+
xmin,
89+
ymin,
90+
xmax,
91+
ymax,
92+
*size,
93+
*location,
94+
yaw,
95+
],
96+
)
97+
)
98+
kitti_annotation_lines.append(kitti_obj)
99+
return kitti_annotation_lines
100+
101+
102+
def _parse_bbox_2d(outer_points):
103+
xmin_nonclip, ymin_nonclip = np.min(outer_points, axis=0)
104+
xmax_nonclip, ymax_nonclip = np.max(outer_points, axis=0)
105+
xmin, ymin = np.clip([xmin_nonclip, ymin_nonclip], a_min=0, a_max=IMAGE_DIMS)
106+
xmax, ymax = np.clip([xmax_nonclip, ymax_nonclip], a_min=0, a_max=IMAGE_DIMS)
107+
new_area = (xmax - xmin) * (ymax - ymin)
108+
old_area = (xmax_nonclip - xmin_nonclip) * (ymax_nonclip - ymin_nonclip)
109+
truncation = 1 - new_area / old_area if old_area > 0.1 else 0
110+
return truncation, xmax, xmin, ymax, ymin
111+
112+
113+
def _lidar_to_camera(objects, calib):
114+
for obj in objects:
115+
if obj.marking3d is None:
116+
continue
117+
transform = get_3d_transform_camera_lidar(calib)
118+
obj.marking3d[ROTATION] = transform_rotation(obj.marking3d[ROTATION], transform)
119+
obj.marking3d[LOCATION] = rigid_transform_3d(obj.marking3d[LOCATION], transform)
120+
return objects
121+
122+
123+
def convert_annotation(calib_path, src_anno_pth, target_path):
124+
with open(src_anno_pth) as anno_file:
125+
src_anno = json.load(anno_file)
126+
vehicle, camera_name, time_str, id_ = basename(src_anno_pth.strip(".json")).split("_")
127+
id_ = int(id_)
128+
objects = ObjectAnnotationHandler.from_annotations(src_anno)
129+
objects = [obj[2] for obj in objects]
130+
131+
# Convert objects from LIDAR to camera using calibration information
132+
frame_time = datetime.strptime(time_str, TIME_FORMAT)
133+
calib = load_calib_from_json(calib_path, vehicle, frame_time, camera_name)
134+
objects = _lidar_to_camera(objects, calib)
135+
# Write a KITTI-style annotation with obj in camera frame
136+
target_anno = _convert_to_kitti(objects, yaw_func=lambda rot: -rot.yaw_pitch_roll[0])
137+
with open(join(target_path, f"{id_:06d}.txt"), "w") as target_file:
138+
target_file.write("\n".join(target_anno))
139+
140+
141+
def _parse_args():
142+
parser = argparse.ArgumentParser(description="Convert annotations to KITTI format")
143+
parser.add_argument("--dataset-dir", required=True, help="Root dataset directory")
144+
parser.add_argument("--target-dir", required=True, help="Output directory")
145+
return parser.parse_args()
146+
147+
148+
def main():
149+
args = _parse_args()
150+
calib_path = join(args.dataset_dir, "calibration")
151+
source_path = join(args.dataset_dir, "annotations", "dynamic_objects")
152+
assert args.dataset_dir not in args.target_dir, "Do not write to the dataset"
153+
154+
print("Looking up all source annotations...")
155+
source_anno_paths = glob.glob(f"{source_path}/*/*.json")
156+
157+
# Create target directories
158+
os.makedirs(args.target_dir, exist_ok=True)
159+
160+
for src_anno_pth in tqdm(source_anno_paths, desc="Converting annotations..."):
161+
try:
162+
convert_annotation(calib_path, src_anno_pth, args.target_dir)
163+
except Exception as err:
164+
print("Failed converting annotation: ", src_anno_pth, "with error:", str(err))
165+
raise
166+
167+
168+
if __name__ == "__main__":
169+
main()

‎eval/prepare_eval.py

+122
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""This script takes the provided raw detections and converts them to a evaluation-friendly format."""
2+
import argparse
3+
import glob
4+
import json
5+
import os
6+
import shutil
7+
from datetime import datetime
8+
9+
import numpy as np
10+
from pyquaternion import Quaternion
11+
from tqdm import tqdm
12+
13+
from calibration import (
14+
get_3d_transform_camera_lidar,
15+
transform_rotation,
16+
rigid_transform_3d,
17+
load_calib_from_json,
18+
)
19+
from constants import TIME_FORMAT
20+
21+
22+
def _get_train_ids(dataset_root: str):
23+
with open(os.path.join(dataset_root, "train.json")) as datalist_file:
24+
datalist = json.load(datalist_file)
25+
return list(datalist.keys())
26+
27+
28+
def _get_test_ids(dataset_root: str):
29+
with open(os.path.join(dataset_root, "test.json")) as datalist_file:
30+
datalist = json.load(datalist_file)
31+
return list(datalist.keys())
32+
33+
34+
def _prepare_camera_detection(dataset_dir, camera_output_dir, id_):
35+
"""Copy camera detection."""
36+
camera_detection_path = os.path.join(dataset_dir, "detections", "camera")
37+
camera_new_path = os.path.join(camera_output_dir, f"{int(id_):06d}.txt")
38+
camera_old_path = list(sorted(glob.glob(os.path.join(camera_detection_path, id_, "*"))))[1]
39+
shutil.copy(camera_old_path, camera_new_path)
40+
return camera_old_path
41+
42+
43+
def _prepare_lidar_detection(dataset_dir, lidar_output_dir, id_, camera_detection_path):
44+
"""Prepare lidar detection file, including coordinate transformation."""
45+
# Find the lidar frame closest to the annotated camera timestamp
46+
lidar_detection_path = os.path.join(dataset_dir, "detections", "lidar")
47+
camera_timestamp = datetime.strptime(
48+
os.path.basename(camera_detection_path).split("_")[2], TIME_FORMAT
49+
)
50+
lidar_paths = list(sorted(glob.glob(os.path.join(lidar_detection_path, id_, "*"))))
51+
lidar_timestamps = [
52+
datetime.strptime(os.path.basename(lidar_path).split("_")[1], TIME_FORMAT)
53+
for lidar_path in lidar_paths
54+
]
55+
diffs = [abs((camera_timestamp - lid_time).total_seconds()) for lid_time in lidar_timestamps]
56+
_, min_idx = min((diff, idx) for (idx, diff) in enumerate(diffs))
57+
lidar_path = lidar_paths[min_idx]
58+
59+
# Load calibration
60+
calib_path = os.path.join(dataset_dir, "calibration")
61+
# example name: golf_FC_2021-04-22T07:03:36.859402Z_0.txt
62+
vehicle, camera_name, _, _ = os.path.basename(camera_detection_path).split("_")
63+
calib = load_calib_from_json(calib_path, vehicle, camera_timestamp, camera_name)
64+
65+
# Modify the detections
66+
new_lidar_lines = []
67+
with open(lidar_path) as lidar_file:
68+
for line in lidar_file:
69+
line = line.split(" ")
70+
pos = np.array([float(val) for val in line[11:14]])
71+
rot = Quaternion(axis=[0.0, 0.0, 1.0], angle=float(line[14]))
72+
transform = get_3d_transform_camera_lidar(calib)
73+
# The transformed rotation will have a 90deg roll and the rotation around camera y is -yaw
74+
new_rot = str(-transform_rotation(rot, transform).yaw_pitch_roll[0])
75+
new_pos = list(map(str, rigid_transform_3d(pos, transform)))
76+
line[11:14], line[14] = new_pos, new_rot
77+
# Change ymax to pass the 25px eval height check (ymin is 0)
78+
line[7] = "25.1"
79+
new_lidar_lines.append(line)
80+
81+
# Write new detection file
82+
new_path = os.path.join(lidar_output_dir, f"{int(id_):06d}.txt")
83+
with open(new_path, "w") as new_file:
84+
for line in new_lidar_lines:
85+
new_file.write(" ".join(line))
86+
87+
# TODO: merge camera and lidar to one file for joint eval
88+
89+
90+
def parse_args():
91+
parser = argparse.ArgumentParser()
92+
parser.add_argument("--dataset-dir", required=True, help='root dataset directory')
93+
parser.add_argument("--tmp-det-dir", default="/tmp/detections")
94+
parser.add_argument("--test", action="store_true")
95+
return parser.parse_args()
96+
97+
98+
def main(args):
99+
if args.test:
100+
print("Preparing eval on test data")
101+
ids = _get_test_ids(args.dataset_dir)
102+
else:
103+
print(f"Preparing eval on the train data")
104+
ids = _get_train_ids(args.dataset_dir)
105+
106+
# Prepare paths
107+
camera_output_dir = os.path.join(args.tmp_det_dir, "camera", "data")
108+
lidar_output_dir = os.path.join(args.tmp_det_dir, "lidar", "data")
109+
os.makedirs(camera_output_dir, exist_ok=True)
110+
os.makedirs(lidar_output_dir, exist_ok=True)
111+
112+
for id_ in tqdm(ids):
113+
camera_detection_path = _prepare_camera_detection(args.dataset_dir, camera_output_dir, id_)
114+
_prepare_lidar_detection(args.dataset_dir, lidar_output_dir, id_, camera_detection_path)
115+
116+
print(f"stored the relevant detections in {args.tmp_det_dir}")
117+
118+
119+
if __name__ == "__main__":
120+
args = parse_args()
121+
print("Command Line Args:", args)
122+
main(args)

0 commit comments

Comments
 (0)
Please sign in to comment.