File size: 12,456 Bytes
7cc5047
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
# SPDX-License-Identifier: Apache-2.0
"""The CPU reference (``reference/``: ONNX initializers by node, every BN folded where the port folds it, torch fp32)
vs the deployed graph's oracles (PLAN.md 0.3 item 1; BUNDLE_CONVENTIONS.md section 10), on identical inputs (the
host pre-processing of ``host/``). ONNX Runtime cannot load ``bevfusion_lidar.onnx`` (its sparse convs are TensorRT
plugins, S:bevfusion:343-351), so the oracle is split as the SPEC prescribes (SPEC 6.2, PLAN.md 2.6 "CPU reference"):

- **sparse encoder** vs the research reference (``research/bevfusion/scripts/bevfusion_ref.py``: its own hashed
  rulebooks and an ``index_add_`` scatter form with the BatchNorms unfolded, independent of this bundle's C13 gather
  maps): every layer output (conv_input, both blocks of every level, the three strided convs, conv_out) and the
  to-dense BEV at PCC >= 0.9999, the active sets of every level identical;
- **dense part before the data-dependent selection** vs ONNX Runtime on the deployed graph's plugin-free dense
  sub-graph fed with the same BEV: every tap (SECOND convs and blocks, deblocks, neck, shared conv, heatmap head,
  sigmoid heat, the local-max heatmap, the key position embedding) at PCC >= 0.9999;
- **the top-500 selection set-based**: >= 99 % of the set shared, and every ORT proposal with heat >= 0.1 (the
  strong proposals) in the reference's set;
- **after it teacher-forced**: the reference run with ORT's own top-k indices reproduces every downstream tap
  (query features / positions / heat, decoder layers, prediction heads, ``bbox_pred``, ``score``) at PCC >= 0.9999
  and ``label_pred`` exactly;
- **end to end set-based**: the same detections after Autoware's post-processing (same label, centre within 1 cm,
  score within 1e-4) and the same counts at every stage;
- the research goldens (the research reference end to end: numpy voxelizer, torch scatter encoder, ORT dense): the
  same voxel tensors and BEV, the same heatmap, top-500 set and detections.

Cases: the shipped samples (PandaSet 019 f40, test.pcd), the Autoware sample-rosbag top scan #144 (the SPEC's frame;
research data, license unstated) and nuScenes mini_val scene-0103 key-frame 20 (sparse 32-beam; research only).
Runs in the research venv (onnxruntime + torch, CPU only; threads <= 4)::

    cd bundles/bevfusion-p150 && OMP_NUM_THREADS=4 PYTHONPATH=code \\
        /home/ubuntu/experiments/tt-models/tools/research-venv/bin/python -m pytest -q -p no:cacheprovider -s \\
        code/tt_bevfusion/tests/test_reference_cpu.py

Skipped where onnxruntime, the weights or the research reference are absent.
"""
from __future__ import annotations

import json

import numpy as np
import pytest

pytest.importorskip("onnxruntime")
pytest.importorskip("onnx")
torch = pytest.importorskip("torch")

from tt_bevfusion.reference.model import SELECTION_TAPS  # noqa: E402
from tt_bevfusion.reference.ort import DENSE_ORT_TAPS  # noqa: E402
from tt_bevfusion.reference.pipeline import BEVFusionReference  # noqa: E402
from tt_bevfusion.reference.weights import ENCODER_PLAN  # noqa: E402
from tt_bevfusion.tests import _data  # noqa: E402
from tt_bevfusion.ttaw.golden import TapRegistry  # noqa: E402
from tt_bevfusion.ttaw.metrics import match_detections, pcc  # noqa: E402

PCC_MIN = 0.9999            # PLAN.md 0.3 item 1
THREADS = 4
STRONG = 0.1                # strong proposals (heat >= 0.1) must all be selected
CASES = {
    "pandaset_019_f40": ("sample", "pandaset_019_f40.npz"),
    "test_pcd": ("sample", "test_pcd.npz"),
    "rosbag_0144": ("research", "rosbag_0144"),
    "ns0103_k20": ("research", "ns0103_k20"),
}


@pytest.fixture(scope="module")
def references():
    w = _data.weights_dir()
    torch.set_num_threads(THREADS)
    return (BEVFusionReference(w, backend="torch", threads=THREADS),
            BEVFusionReference(w, backend="hybrid", threads=THREADS, intermediates=True))


def _source(case):
    kind, name = CASES[case]
    return _data.sample_path(name) if kind == "sample" else _data.research_cloud(name)


_RUNS = {}


def _runs(references, case):
    if case not in _RUNS:
        rt, rh = references
        src = _source(case)
        tt, th = TapRegistry(), TapRegistry()             # every tap (module and per layer)
        a = rt.run(src, taps=tt)
        b = rh.run(src, taps=th)
        _RUNS.clear()                                     # keep one case in memory
        _RUNS[case] = (a, b, tt, th)
    return _RUNS[case]


def _research_encoder_taps(R, inits, vfe, coors):
    """The research reference's sparse encoder (``SparseEncoderRef.forward``) layer by layer, with its own
    rulebooks, scatter-form convs and unfolded BatchNorms: {tap name: (N, C) features} + the level coords."""
    enc = R.SparseEncoderRef(inits)
    P = enc.P
    out, coords = {}, {}
    x = torch.from_numpy(vfe.astype(np.float32))
    c = torch.from_numpy(coors[:, ::-1].astype(np.int64).copy())
    level = R.SparseLevel(c, enc.SPATIAL)
    coords["l1"] = level.coords.numpy()
    rb = R.subm_rulebook(level, (3, 3, 3), (1, 1, 1))
    x = torch.relu(enc.bn(R.apply_rulebook(x, rb, enc.w[P + "conv_input.0.weight"], len(level.coords)),
                          P + "conv_input.1"))
    out["sparse.conv_input"] = x
    for stage, nblocks, down in enc.STAGES:
        rb = R.subm_rulebook(level, (3, 3, 3), (1, 1, 1))
        for blk in range(nblocks):
            pre = f"{P}encoder_layers.encoder_layer{stage}.{blk}."
            idn = x
            y = torch.relu(enc.bn(R.apply_rulebook(x, rb, enc.w[pre + "conv1.weight"], len(level.coords)),
                                  pre + "bn1"))
            out[f"sparse.layer{stage}.{blk}.conv1"] = y
            y = enc.bn(R.apply_rulebook(y, rb, enc.w[pre + "conv2.weight"], len(level.coords)), pre + "bn2")
            x = torch.relu(y + idn)
            out[f"sparse.layer{stage}.{blk}"] = x
        if down is not None:
            _, _, pad = down
            pre = f"{P}encoder_layers.encoder_layer{stage}.2."
            rb_d, level = R.strided_rulebook(level, (3, 3, 3), (2, 2, 2), pad)
            x = torch.relu(enc.bn(R.apply_rulebook(x, rb_d, enc.w[pre + "0.weight"], len(level.coords)), pre + "1"))
            out[f"sparse.down{stage}"] = x
            coords[f"l{stage + 1}"] = level.coords.numpy()
    rb_o, out_level = R.strided_rulebook(level, (1, 1, 3), (1, 1, 2), (0, 0, 0))
    x = torch.relu(enc.bn(R.apply_rulebook(x, rb_o, enc.w[P + "conv_out.0.weight"], len(out_level.coords)),
                          P + "conv_out.1"))
    out["sparse.conv_out"] = x
    coords["out"] = out_level.coords.numpy()
    return {k: v.numpy() for k, v in out.items()}, coords


@pytest.mark.parametrize("case", list(CASES))
def test_sparse_encoder_matches_the_research_reference(references, case):
    rt, _ = references
    a, _, tt, _ = _runs(references, case)
    R = _data.research_module()
    inits = R.load_initializers(str(_data.weights_dir() / _data.ONNX))
    ref, coords = _research_encoder_taps(R, inits, a.prepared.vfe, a.prepared.coors)
    plan = a.prepared.plan
    for lvl, c in coords.items():                       # identical active sets (the research's in key order)
        mine = plan.levels[lvl].coords
        assert len(mine) == len(c), lvl
        if lvl != "l1":
            assert np.array_equal(mine, c), lvl         # both sort strided outputs by the (x, y, z) linear key
    names = [name for name, *_ in ENCODER_PLAN]
    worst = {name: pcc(tt[name], ref[name]) for name in names}
    worst["sparse.bev"] = pcc(tt["sparse.bev"], R.SparseEncoderRef(inits).forward(a.prepared.vfe, a.prepared.coors))
    print(case, plan.describe()["counts"], json.dumps({k: round(v, 10) for k, v in
                                                       sorted(worst.items(), key=lambda kv: kv[1])[:4]}))
    bad = {k: v for k, v in worst.items() if not v >= PCC_MIN}
    assert not bad, bad


@pytest.mark.parametrize("case", list(CASES))
def test_dense_taps_before_the_selection_match_onnxruntime(references, case):
    a, b, tt, th = _runs(references, case)
    assert np.array_equal(a.prepared.vfe, b.prepared.vfe)
    names = [n for n in DENSE_ORT_TAPS if n not in SELECTION_TAPS]
    assert set(names) <= set(th.names()) and set(names) <= set(tt.names())
    worst = {}
    for name in names:
        x, y = tt[name], th[name]
        assert x.size == y.size, name
        worst[name] = pcc(x, y.reshape(x.shape))
    print(case, json.dumps({k: round(v, 10) for k, v in sorted(worst.items(), key=lambda kv: kv[1])[:5]}))
    bad = {k: v for k, v in worst.items() if not v >= PCC_MIN}
    assert not bad, bad


@pytest.mark.parametrize("case", list(CASES))
def test_topk_selection_set_based(references, case):
    _, _, tt, th = _runs(references, case)
    mine, ref = tt["head.topk_indices"].ravel(), th["head.topk_indices"].ravel()
    k = ref.size
    shared = len(set(mine.tolist()) & set(ref.tolist()))
    strong = ref[th["head.topk_values"].ravel() >= STRONG]
    print(case, f"top-{k} shared {shared}, strong {len(strong)}, positional {np.mean(mine == ref):.4f}")
    assert k == 500 and shared >= 0.99 * k
    assert set(strong.tolist()) <= set(mine.tolist())
    np.testing.assert_allclose(np.sort(tt["head.topk_values"].ravel()), np.sort(th["head.topk_values"].ravel()),
                               atol=1e-4)


@pytest.mark.parametrize("case", list(CASES))
def test_teacher_forced_decoder_matches_onnxruntime(references, case):
    rt, _ = references
    _, b, _, th = _runs(references, case)
    tf = TapRegistry(include=SELECTION_TAPS)
    rt.network(b.prepared, tf, topk_indices=th["head.topk_indices"])
    assert np.array_equal(tf["head.topk_indices"], th["head.topk_indices"])
    assert np.array_equal(tf["label_pred"], th["label_pred"])
    worst = {}
    for name in SELECTION_TAPS:
        if name in ("head.topk_indices", "label_pred"):
            continue
        x, y = tf[name], th[name]
        assert x.size == y.size, name
        worst[name] = pcc(x, y.reshape(x.shape))
    print(case, json.dumps({k: round(v, 10) for k, v in sorted(worst.items(), key=lambda kv: kv[1])[:5]}))
    bad = {k: v for k, v in worst.items() if not v >= PCC_MIN}
    assert not bad, bad
    assert np.array_equal(tf["query.pos"], th["query.pos"])               # exact arithmetic query_pos


@pytest.mark.parametrize("case", list(CASES))
def test_detections_match_onnxruntime_set_based(references, case):
    a, b, _, _ = _runs(references, case)
    assert a.counts == b.counts
    da, db = a.output, b.output
    m = match_detections(da.boxes, da.label_ids, da.scores, db.boxes, db.label_ids, db.scores, max_dist=0.01)
    assert m.recall == 1.0 and m.precision == 1.0, m.to_dict()
    assert max(m.score_errors, default=0.0) < 1e-4
    assert len(da) == len(db) and len(da) > 0


@pytest.mark.parametrize("case", ["rosbag_0144", "pandaset_019_f40", "ns0103_k20"])
def test_reproduces_the_research_goldens(references, case):
    """The research reference's goldens of the same frame (rosbag: ``golden_leveled/golden_lidar.npz``; public:
    ``public_data/golden/<id>_lidar.npz``): identical voxel tensors, the BEV (when stored), the sigmoid heatmap,
    the top-500 set and the Autoware detections (labels after the remapper, centre within 1 cm, score within 1e-4)."""
    a, _, tt, _ = _runs(references, case)
    g = _data.research_golden("ps019_f40" if case == "pandaset_019_f40" else case)
    prep = a.prepared
    assert np.array_equal(prep.coors, g["coors"]) and np.array_equal(prep.voxel_set.num_points,
                                                                     g["num_points_per_voxel"])
    assert np.array_equal(prep.vfe, g["vfe"])
    if "bev_lidar" in g.files:
        assert pcc(tt["sparse.bev"], g["bev_lidar"]) >= PCC_MIN
    if "heatmap" in g.files:
        assert pcc(tt["head.heat"], g["heatmap"]) >= PCC_MIN
    if "topk_indices" in g.files:
        shared = len(set(tt["head.topk_indices"].ravel().tolist()) & set(g["topk_indices"].ravel().tolist()))
        assert shared >= 495
    det = g["detections"]                                # label (Autoware), score, x, y, z, l, w, h, yaw, vx, vy
    out = a.output
    m = match_detections(out.boxes, out.label_ids, out.scores, det[:, [2, 3, 4, 5, 6, 7, 8]], det[:, 0].astype(int),
                         det[:, 1], max_dist=0.01)
    assert m.recall == 1.0 and m.precision == 1.0, m.to_dict()
    assert max(m.score_errors, default=0.0) < 1e-4