File size: 8,082 Bytes
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
"""Strict Quality Validation Engine: deterministic geometric plausibility checks.

Compares LiDAR point cloud vs bounding-box proposal (spec §8) and computes
per-3D-box metrics with configurable tolerances (spec §9): 3D IoU, position
error, orientation error, dimension error, point density, point-in-box ratio,
ground contact error, plus sensor-consistency checks.
"""

from __future__ import annotations

import math
from typing import Dict, List, Optional

import numpy as np

from sensorflow.evaluation.records import (
    Annotation,
    CheckLine,
    EvalStore,
    Frame,
    ValidationResult,
)
from sensorflow.evaluation.synthetic import (
    CLASS_DIMS,
    frame_points,
    points_in_box,
    project_bbox_2d,
)
from sensorflow.metrics.perception_3d import bev_iou

DEFAULT_TOLERANCES: Dict[str, float] = {
    "min_points_in_box": 12,
    "min_point_density": 0.4,       # points per m^3
    "min_box_occupancy": 0.05,      # fraction of nearby points captured
    "max_dim_ratio": 1.6,           # vs class prior
    "min_dim_ratio": 0.55,
    "max_centroid_offset_m": 1.2,   # points centroid vs box center (BEV)
    "max_ground_contact_error_m": 0.5,
    "max_orientation_track_dev_deg": 45.0,
    "min_iou_3d": 0.5,              # plausibility floor (triage gate is stricter)
    "max_position_error_m": 1.0,
    "max_orientation_error_deg": 30.0,
    "max_dimension_error": 0.5,
}


def iou_3d(box_a: List[float], box_b: List[float]) -> float:
    """BEV IoU extended with vertical overlap."""
    biou = bev_iou(box_a, box_b)
    za0, za1 = box_a[2] - box_a[5] / 2, box_a[2] + box_a[5] / 2
    zb0, zb1 = box_b[2] - box_b[5] / 2, box_b[2] + box_b[5] / 2
    inter_h = max(0.0, min(za1, zb1) - max(za0, zb0))
    union_h = max(za1, zb1) - min(za0, zb0)
    if union_h <= 0:
        return 0.0
    return biou * (inter_h / union_h) * (union_h / max(box_a[5], box_b[5]))  # approx volumetric


def validate_annotation(
    store: EvalStore,
    ann: Annotation,
    frame: Frame,
    points: Optional[np.ndarray] = None,
    tolerances: Optional[Dict[str, float]] = None,
) -> ValidationResult:
    tol = dict(DEFAULT_TOLERANCES)
    if tolerances:
        tol.update(tolerances)

    checks: List[CheckLine] = []
    result = ValidationResult(annotation_id=ann.annotation_id)

    if not ann.bbox_3d:
        result.passed = False
        result.checks = [CheckLine(gate="has_bbox_3d", actual=False, threshold=True, passed=False)]
        store.put("validations", result)
        return result

    if points is None:
        points = frame_points(store, frame)

    x, y, z, l, w, h, yaw = ann.bbox_3d
    volume = max(l * w * h, 1e-3)

    # --- point support
    mask = points_in_box(points, ann.bbox_3d)
    n_in = int(mask.sum())
    density = n_in / volume
    result.point_density = round(density, 3)

    checks.append(CheckLine(gate="points_in_box", actual=n_in,
                            threshold=tol["min_points_in_box"],
                            passed=n_in >= tol["min_points_in_box"]))
    checks.append(CheckLine(gate="point_density_per_m3", actual=round(density, 2),
                            threshold=tol["min_point_density"],
                            passed=density >= tol["min_point_density"]))

    # --- box occupancy: fraction of points near the box that fall inside it
    near = points[(np.abs(points[:, 0] - x) < max(l, 4)) & (np.abs(points[:, 1] - y) < max(w, 4))]
    above_ground = near[near[:, 2] > 0.15] if len(near) else near
    occupancy = n_in / max(len(above_ground), 1)
    result.point_in_box_ratio = round(min(occupancy, 1.0), 4)
    checks.append(CheckLine(gate="box_occupancy", actual=round(occupancy, 3),
                            threshold=tol["min_box_occupancy"],
                            passed=occupancy >= tol["min_box_occupancy"]))

    # --- dimension plausibility vs class prior
    exp = CLASS_DIMS.get(ann.class_name)
    if exp:
        ratio = float(np.mean([l / exp[0], w / exp[1], h / exp[2]]))
        ok = tol["min_dim_ratio"] <= ratio <= tol["max_dim_ratio"]
        checks.append(CheckLine(gate="box_dimensions_vs_class_prior", actual=round(ratio, 3),
                                threshold=f"[{tol['min_dim_ratio']}, {tol['max_dim_ratio']}]", passed=ok))
    else:
        checks.append(CheckLine(gate="box_dimensions_vs_class_prior", actual="unknown_class",
                                threshold="known class", passed=True, applicable=False))

    # --- centroid consistency: LiDAR centroid vs box center
    if n_in >= 3:
        centroid = points[mask].mean(axis=0)
        offset = math.hypot(centroid[0] - x, centroid[1] - y)
        checks.append(CheckLine(gate="centroid_consistency_m", actual=round(offset, 3),
                                threshold=tol["max_centroid_offset_m"],
                                passed=offset <= tol["max_centroid_offset_m"]))
    else:
        checks.append(CheckLine(gate="centroid_consistency_m", actual="insufficient points",
                                threshold=tol["max_centroid_offset_m"], passed=False, applicable=n_in > 0))

    # --- object-to-ground relationship
    ground_err = abs((z - h / 2) - 0.0)
    result.ground_contact_error = round(ground_err, 3)
    checks.append(CheckLine(gate="ground_contact_error_m", actual=round(ground_err, 3),
                            threshold=tol["max_ground_contact_error_m"],
                            passed=ground_err <= tol["max_ground_contact_error_m"]))

    # --- sensor consistency: LiDAR box should have a camera projection when
    #     visible; a projectable box missing a 2D detection is a disagreement.
    expected_2d = project_bbox_2d(ann.bbox_3d)
    sensor_ok = not (expected_2d is not None and ann.bbox_2d is None)
    result.sensor_consistent = sensor_ok
    checks.append(CheckLine(gate="sensor_consistency_cam_lidar",
                            actual="2d_present" if ann.bbox_2d else "2d_missing",
                            threshold="camera+lidar agree", passed=sensor_ok,
                            applicable=expected_2d is not None))

    # --- reference comparison (only when a reference ground truth exists)
    gt = next((g for g in frame.gt_boxes if g.gt_id == ann.matched_gt_id), None)
    if gt is not None:
        iou = iou_3d(ann.bbox_3d, gt.bbox_3d)
        pos_err = math.hypot(x - gt.bbox_3d[0], y - gt.bbox_3d[1])
        yaw_err = abs(yaw - gt.bbox_3d[6]) % (2 * math.pi)
        yaw_err = min(yaw_err, 2 * math.pi - yaw_err)
        yaw_err_deg = math.degrees(yaw_err)
        dim_err = float(np.mean([abs(ann.bbox_3d[i] - gt.bbox_3d[i]) / max(gt.bbox_3d[i], 0.1) for i in (3, 4, 5)]))

        result.iou_3d = round(iou, 4)
        result.position_error = round(pos_err, 3)
        result.orientation_error_deg = round(yaw_err_deg, 2)
        result.dimension_error = round(dim_err, 4)

        checks.append(CheckLine(gate="iou_3d_vs_reference", actual=round(iou, 3),
                                threshold=tol["min_iou_3d"], passed=iou >= tol["min_iou_3d"]))
        checks.append(CheckLine(gate="position_error_m", actual=round(pos_err, 3),
                                threshold=tol["max_position_error_m"],
                                passed=pos_err <= tol["max_position_error_m"]))
        checks.append(CheckLine(gate="orientation_error_deg", actual=round(yaw_err_deg, 1),
                                threshold=tol["max_orientation_error_deg"],
                                passed=yaw_err_deg <= tol["max_orientation_error_deg"]))
        checks.append(CheckLine(gate="dimension_error", actual=round(dim_err, 3),
                                threshold=tol["max_dimension_error"],
                                passed=dim_err <= tol["max_dimension_error"]))
    else:
        checks.append(CheckLine(gate="iou_3d_vs_reference", actual="no reference GT",
                                threshold=tol["min_iou_3d"], passed=True, applicable=False))

    result.checks = checks
    result.passed = all(c.passed for c in checks if c.applicable)
    store.put("validations", result)
    return result