Skip to content

Commit ad0c62f

Browse files
committed
feat: add average precision metric (#134)
* feat: add AP metric Signed-off-by: ktro2828 <kotaro.uetake@tier4.jp> * feat: add scorer for heading yaw and APH Signed-off-by: ktro2828 <kotaro.uetake@tier4.jp> * feat: specify ego2map Signed-off-by: ktro2828 <kotaro.uetake@tier4.jp> * refactor: add method to configure scorer Signed-off-by: ktro2828 <kotaro.uetake@tier4.jp> --------- Signed-off-by: ktro2828 <kotaro.uetake@tier4.jp>
1 parent c62b21a commit ad0c62f

4 files changed

Lines changed: 175 additions & 2 deletions

File tree

t4_devkit/evaluation/matching/parameter.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ class MatchingScorer(str, Enum):
8787
PLANE_DISTANCE = "PLANE_DISTANCE"
8888
IOU2D = "IOU2D"
8989
IOU3D = "IOU3D"
90+
HEADING_YAW = "HEADING_YAW"
9091

9192

9293
class MatchingPolicy(str, Enum):

t4_devkit/evaluation/matching/scorer.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"PlaneDistance",
2020
"Iou2D",
2121
"Iou3D",
22+
"HeadingYaw",
2223
]
2324

2425

@@ -75,12 +76,18 @@ def is_smaller_score_better(cls) -> bool:
7576
pass
7677

7778
@abstractmethod
78-
def _calculate_score(self, box1: BoxLike, box2: BoxLike) -> float:
79+
def _calculate_score(
80+
self,
81+
box1: BoxLike,
82+
box2: BoxLike,
83+
ego2map: HomogeneousMatrix | None = None,
84+
) -> float:
7985
"""Calculate the matching score of the input two boxes.
8086
8187
Args:
8288
box1 (BoxLike): A box.
8389
box2 (BoxLike): A box.
90+
ego2map (HomogeneousMatrix | None, optional): Transformation matrix from map to ego frame.
8491
8592
Returns:
8693
Calculated matching score.
@@ -159,7 +166,11 @@ def _calculate_score(
159166
return distance
160167

161168
def _transform2ego(self, box: Box3D, ego2map: HomogeneousMatrix | None = None) -> Box3D:
162-
"""Transform the box to base link frame if it is not."""
169+
"""Transform the box to base link frame if it is not.
170+
171+
Todo:
172+
This method should be function.
173+
"""
163174
if box.frame_id == "base_link":
164175
return box
165176

@@ -235,3 +246,61 @@ def _calculate_score(
235246
intersection = compute_volume_intersection(box1, box2)
236247
union = box1.volume + box2.volume - intersection
237248
return intersection / union
249+
250+
251+
class HeadingYaw(MatchingScorerImpl):
252+
def _validate(self, box1, box2):
253+
super()._validate(box1, box2)
254+
255+
if not isinstance(box1, Box3D):
256+
raise TypeError("For Iou3D, input boxes must be 3D.")
257+
258+
@classmethod
259+
def is_smaller_score_better(cls) -> bool:
260+
return True
261+
262+
def _calculate_score(
263+
self,
264+
box1: Box3D,
265+
box2: Box3D,
266+
ego2map: HomogeneousMatrix | None = None,
267+
) -> float:
268+
box1 = self._transform2ego(box1, ego2map)
269+
box2 = self._transform2ego(box2, ego2map)
270+
271+
return abs(box2.diff_yaw(box1))
272+
273+
def _transform2ego(self, box: Box3D, ego2map: HomogeneousMatrix | None = None) -> Box3D:
274+
"""Transform the box to base link frame if it is not.
275+
276+
Todo:
277+
This method should be function.
278+
"""
279+
if box.frame_id == "base_link":
280+
return box
281+
282+
if ego2map is None:
283+
raise ValueError(f"For {box.frame_id}, `ego2map` must be specified.")
284+
285+
matrix = HomogeneousMatrix(
286+
position=box.position,
287+
rotation=box.rotation,
288+
src=box.frame_id,
289+
dst="base_link",
290+
)
291+
292+
tf = ego2map.inv().transform(matrix=matrix)
293+
294+
return Box3D(
295+
unix_time=box.unix_time,
296+
frame_id="base_link",
297+
semantic_label=box.semantic_label,
298+
position=tf.position,
299+
rotation=tf.rotation,
300+
shape=box.shape,
301+
velocity=box.velocity,
302+
num_points=box.num_points,
303+
future=box.future,
304+
confidence=box.confidence,
305+
uuid=box.uuid,
306+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .ap import * # noqa

t4_devkit/evaluation/metric/ap.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
import numpy as np
6+
from attrs import define, field
7+
8+
from ..matching import CenterDistance, HeadingYaw
9+
10+
if TYPE_CHECKING:
11+
from t4_devkit.evaluation import FrameBoxMatch, MatchingScorerLike
12+
13+
__all__ = ["Ap", "ApH"]
14+
15+
16+
class Ap:
17+
num_recall_point = 101
18+
min_precision = 0.1
19+
min_recall = 0.1
20+
21+
@define
22+
class ApBuffer:
23+
"""Buffer to compute AP."""
24+
25+
num_gt: int = field(init=False, default=0)
26+
tp_list: list[float] = field(init=False, factory=list)
27+
fp_list: list[float] = field(init=False, factory=list)
28+
confidences: list[float] = field(init=False, factory=list)
29+
30+
def compute_ap(self) -> float:
31+
"""Compute average precision."""
32+
if self.num_gt == 0:
33+
return 0.0
34+
35+
sorted_idx = np.argsort(self.confidences)[::-1]
36+
tp_sorted = np.array(self.tp_list)[sorted_idx]
37+
fp_sorted = np.array(self.fp_list)[sorted_idx]
38+
39+
tps = np.cumsum(tp_sorted)
40+
fps = np.cumsum(fp_sorted)
41+
42+
precisions = []
43+
recalls = []
44+
for tp, fp in zip(tps, fps, strict=True):
45+
denominator = tp + fp
46+
precisions.append(0.0) if denominator == 0.0 else precisions.append(
47+
tp / denominator
48+
)
49+
recalls.append(tp / self.num_gt)
50+
51+
precision_envelope = np.maximum.accumulate(precisions[::-1])[::-1]
52+
53+
recall_interp = np.linspace(0.0, 1.0, Ap.num_recall_point)
54+
55+
precision_interp = np.interp(recall_interp, recalls, precision_envelope, right=0)
56+
57+
first_idx = int(round(100 * Ap.min_recall))
58+
59+
filtered_precision = precision_interp[first_idx:] - Ap.min_precision
60+
filtered_precision = np.clip(filtered_precision, 0.0, None)
61+
62+
return float(np.mean(filtered_precision)) / (1.0 - Ap.min_precision)
63+
64+
def __init__(self, threshold: float) -> None:
65+
self.scorer = self._configure_scorer()
66+
self.threshold = threshold
67+
68+
def _configure_scorer(self) -> MatchingScorerLike:
69+
return CenterDistance()
70+
71+
def __call__(self, frames: list[FrameBoxMatch]) -> float:
72+
component = self._compute_tp_fp(frames)
73+
return component.compute_ap()
74+
75+
def _compute_tp_fp(self, frames: list[FrameBoxMatch]) -> ApBuffer:
76+
buffer = self.ApBuffer()
77+
for frame in frames:
78+
buffer.num_gt += frame.num_gt
79+
for box_match in frame.matches:
80+
if box_match.estimation is None:
81+
continue
82+
83+
buffer.confidences.append(box_match.estimation.confidence)
84+
if box_match.is_tp(
85+
scorer=self.scorer,
86+
threshold=self.threshold,
87+
ego2map=frame.ego2map,
88+
):
89+
buffer.tp_list.append(1.0)
90+
buffer.fp_list.append(0.0)
91+
else:
92+
buffer.tp_list.append(0.0)
93+
buffer.fp_list.append(1.0)
94+
return buffer
95+
96+
97+
class ApH(Ap):
98+
def __init__(self, threshold: float) -> None:
99+
super().__init__(threshold=threshold)
100+
101+
def _configure_scorer(self) -> HeadingYaw:
102+
return HeadingYaw()

0 commit comments

Comments
 (0)