File size: 7,539 Bytes
729628a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6342806
 
 
 
 
 
 
 
 
 
729628a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6342806
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
729628a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
"""3D perception metrics: mAP, mAR, IoU, orientation/position error."""

from __future__ import annotations

import math
from typing import Dict, List, Tuple

import numpy as np


def _rotate_corners(cx: float, cy: float, l: float, w: float, yaw: float) -> np.ndarray:
    """Return 4 BEV corners of rotated rectangle."""
    cos_y, sin_y = math.cos(yaw), math.sin(yaw)
    corners = np.array([
        [-l / 2, -w / 2], [l / 2, -w / 2], [l / 2, w / 2], [-l / 2, w / 2],
    ])
    rot = np.array([[cos_y, -sin_y], [sin_y, cos_y]])
    return corners @ rot.T + np.array([cx, cy])


def _polygon_area(poly: np.ndarray) -> float:
    x, y = poly[:, 0], poly[:, 1]
    return 0.5 * abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1)))


def _clip_polygon(subject: np.ndarray, clip: np.ndarray) -> np.ndarray:
    """Sutherland-Hodgman polygon clipping (convex)."""
    def inside(p, edge_start, edge_end):
        return (edge_end[0] - edge_start[0]) * (p[1] - edge_start[1]) - \
               (edge_end[1] - edge_start[1]) * (p[0] - edge_start[0]) >= 0

    def intersection(s, e, cp1, cp2):
        dc = cp1 - cp2
        dp = s - e
        n1 = cp1[0] * cp2[1] - cp1[1] * cp2[0]
        n2 = s[0] * e[1] - s[1] * e[0]
        denom = dc[0] * dp[1] - dc[1] * dp[0]
        if abs(denom) < 1e-10:
            return s
        return np.array([(n1 * dp[0] - n2 * dc[0]) / denom, (n1 * dp[1] - n2 * dc[1]) / denom])

    output = subject.copy()
    for i in range(len(clip)):
        input_list = output
        output = []
        cp1, cp2 = clip[i], clip[(i + 1) % len(clip)]
        if len(input_list) == 0:
            break
        s = input_list[-1]
        for e in input_list:
            if inside(e, cp1, cp2):
                if not inside(s, cp1, cp2):
                    output.append(intersection(s, e, cp1, cp2))
                output.append(e)
            elif inside(s, cp1, cp2):
                output.append(intersection(s, e, cp1, cp2))
            s = e
        output = np.array(output) if output else np.array([]).reshape(0, 2)
    return output


def bev_iou(box_a: List[float], box_b: List[float]) -> float:
    """BEV rotated rectangle IoU for bbox_3d [x,y,z,l,w,h,yaw]."""
    xa, ya, la, wa, yaw_a = box_a[0], box_a[1], box_a[3], box_a[4], box_a[6]
    xb, yb, lb, wb, yaw_b = box_b[0], box_b[1], box_b[3], box_b[4], box_b[6]
    poly_a = _rotate_corners(xa, ya, la, wa, yaw_a)
    poly_b = _rotate_corners(xb, yb, lb, wb, yaw_b)
    inter = _clip_polygon(poly_a, poly_b)
    if len(inter) < 3:
        return 0.0
    inter_area = _polygon_area(inter)
    area_a = la * wa
    area_b = lb * wb
    union = area_a + area_b - inter_area
    return inter_area / union if union > 0 else 0.0


def _match_boxes(
    preds: List[List[float]],
    gts: List[List[float]],
    iou_threshold: float,
) -> Tuple[List[Tuple[int, int, float]], List[int], List[int]]:
    """Greedy matching by IoU."""
    matched_pairs = []
    matched_gt = set()
    matched_pred = set()
    ious = []
    for pi, p in enumerate(preds):
        for gi, g in enumerate(gts):
            iou = bev_iou(p, g)
            if iou >= iou_threshold:
                ious.append((iou, pi, gi))
    ious.sort(reverse=True)
    for iou, pi, gi in ious:
        if pi not in matched_pred and gi not in matched_gt:
            matched_pairs.append((pi, gi, iou))
            matched_pred.add(pi)
            matched_gt.add(gi)
    unmatched_pred = [i for i in range(len(preds)) if i not in matched_pred]
    unmatched_gt = [i for i in range(len(gts)) if i not in matched_gt]
    return matched_pairs, unmatched_pred, unmatched_gt


def compute_map_mar(
    all_preds: List[List[float]],
    all_gts: List[List[float]],
    iou_thresholds: List[float] = None,
) -> Dict[str, float]:
    """Set-level precision/recall at fixed IoU thresholds (NOT literature mAP).

    Despite the key names ("map_3d"/"mar_3d", kept for API compatibility),
    this computes greedy-matched precision and recall averaged over the given
    IoU thresholds. It does NOT rank detections by confidence or integrate a
    precision-recall curve, so it is not comparable to COCO/nuScenes mAP.
    Matching is performed over the box sets as given: callers are responsible
    for scoping the inputs to a single frame (see compute_map_mar_by_frame),
    otherwise boxes from different timestamps can match each other.
    """
    iou_thresholds = iou_thresholds or [0.5, 0.7]
    aps, ars = [], []
    for thresh in iou_thresholds:
        pairs, unmatched_pred, unmatched_gt = _match_boxes(all_preds, all_gts, thresh)
        tp = len(pairs)
        fp = len(unmatched_pred)
        fn = len(unmatched_gt)
        precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
        recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
        aps.append(precision)
        ars.append(recall)
    return {
        "map_3d": float(np.mean(aps)) if aps else 0.0,
        "mar_3d": float(np.mean(ars)) if ars else 0.0,
        "mean_iou_3d": float(np.mean([p[2] for p in _match_boxes(all_preds, all_gts, 0.5)[0]])) if all_preds and all_gts and _match_boxes(all_preds, all_gts, 0.5)[0] else 0.0,
    }


def compute_map_mar_by_frame(
    preds_by_frame: Dict[str, List[List[float]]],
    gts_by_frame: Dict[str, List[List[float]]],
    iou_thresholds: List[float] = None,
) -> Dict[str, float]:
    """Frame-scoped precision/recall at fixed IoU thresholds (micro-averaged).

    Matching runs independently within each frame, so a prediction can only
    match ground truth from the same timestamp. TP/FP/FN counts are summed
    across frames before ratios are taken. Same naming caveat as
    compute_map_mar: "map_3d"/"mar_3d" are precision/recall at fixed IoU
    thresholds, not ranked average precision.
    """
    iou_thresholds = iou_thresholds or [0.5, 0.7]
    frame_ids = sorted(set(preds_by_frame) | set(gts_by_frame))
    aps, ars = [], []
    for thresh in iou_thresholds:
        tp = fp = fn = 0
        for fid in frame_ids:
            pairs, unmatched_pred, unmatched_gt = _match_boxes(
                preds_by_frame.get(fid, []), gts_by_frame.get(fid, []), thresh)
            tp += len(pairs)
            fp += len(unmatched_pred)
            fn += len(unmatched_gt)
        aps.append(tp / (tp + fp) if (tp + fp) > 0 else 0.0)
        ars.append(tp / (tp + fn) if (tp + fn) > 0 else 0.0)

    matched_ious = []
    for fid in frame_ids:
        pairs, _, _ = _match_boxes(
            preds_by_frame.get(fid, []), gts_by_frame.get(fid, []), 0.5)
        matched_ious.extend(p[2] for p in pairs)
    return {
        "map_3d": float(np.mean(aps)) if aps else 0.0,
        "mar_3d": float(np.mean(ars)) if ars else 0.0,
        "mean_iou_3d": float(np.mean(matched_ious)) if matched_ious else 0.0,
    }


def mean_orientation_error(matched_pairs: List[Tuple[List[float], List[float]]]) -> float:
    """Mean absolute yaw error in degrees."""
    if not matched_pairs:
        return 0.0
    errors = []
    for pred, gt in matched_pairs:
        diff = abs(pred[6] - gt[6])
        diff = min(diff, 2 * math.pi - diff)
        errors.append(math.degrees(diff))
    return float(np.mean(errors))


def mean_position_error(matched_pairs: List[Tuple[List[float], List[float]]]) -> float:
    """Mean L2 BEV center distance in meters."""
    if not matched_pairs:
        return 0.0
    errors = []
    for pred, gt in matched_pairs:
        dist = math.sqrt((pred[0] - gt[0]) ** 2 + (pred[1] - gt[1]) ** 2)
        errors.append(dist)
    return float(np.mean(errors))