File size: 10,042 Bytes
fe51a63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""Build the SHIPPED samples of frnet-p150: one synthetic LiDAR sweep per serve profile, generated here (no dataset
involved, Apache-2.0), and their stored fp32 CPU-reference outputs (the container smoke test's reference). CPU only,
no device; the reference needs ONNX Runtime (research venv).

    cd bundles/frnet-p150 && OMP_NUM_THREADS=4 PYTHONPATH=code \\
        /home/ubuntu/experiments/tt-models/tools/research-venv/bin/python code/scripts/make_sample.py \\
        [--weights-dir <lidar_frnet v2.0 files>] [--check-against <dir with the same files>]

The scan is a deterministic ray cast from a sensor 2 m above a flat road (seed 0) against 25 cars, 16 building
facades, 30 poles / trees and 10 pedestrians (axis-aligned boxes), with 5 % beam dropout, 2 cm range noise and
random 0-255 intensities, in the **sensor frame** (x forward, y left, z up). The beam layout follows the profile's
own sensor: 128 rows evenly spread over the vertical field of view (OT128 +15 / -25 deg, QT128 +-52.6 deg) and a
fixed number of columns over 360 deg, chosen so that the frame lies inside Autoware's profile (the node skips a frame
with more than 60,000 unique cells): OT128 512 columns, QT128 600 columns. The generator is a copy of the research
reference ``research/frnet/scripts/frnet_preproc_ref.py synthetic_scan`` (``--check-against`` compares the arrays
with an earlier output, e.g. the M5 bench inputs ``logs/frnet/m5_baseline/inputs``).

Writes, per profile ``<v>`` (``ot128``, ``qt128``):

- ``code/tt_frnet/samples/synthetic_<v>.npz``: ``points``, a structured (x, y, z, intensity) float32 array;
- ``code/tt_frnet/calib/synthetic_<v>.json``: the calibration preset of the same name (sensor -> base_link = a pure
  2 m lift: base_link on the road under the sensor), for the ego crop box;
- ``code/tt_frnet/samples/synthetic_<v>.<v>.reference.json``: the ``/predict`` body of the fp32 CPU reference
  (``tt_frnet.reference.pipeline`` with ONNX Runtime on the deployed ONNX, the reference of record), reduced to what
  the container smoke compares (``labels``, ``scores``, ``filter_keep``, npz_compressed) plus the counts.

Regenerate the references whenever the reference, the weights or the post-processing changes.
"""
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

import numpy as np

HERE = Path(__file__).resolve()
CODE = HERE.parents[1]
sys.path.insert(0, str(CODE))

WS = Path("/home/ubuntu/experiments/tt-models")
KEEP = ("model", "frame_id", "num_points", "num_points_raw", "class_names", "class_counts")
# per profile: the sensor's vertical field of view (deg) and rows (the ml_package frustum height) and the columns
SENSORS = {"ot128": dict(fov_up_deg=15.0, fov_down_deg=-25.0, rows=128, cols=512),
           "qt128": dict(fov_up_deg=52.6, fov_down_deg=-52.6, rows=128, cols=600)}
SENSOR_HEIGHT = 2.0
SEED = 0


def synthetic_scan(fov_up_deg: float, fov_down_deg: float, rows: int, n_cols: int, seed: int = 0,
                   sensor_height: float = 2.0, max_range: float = 200.0):
    """Ray cast against a ground plane and boxes; returns ``(points (n, 4) float32 x, y, z, intensity, labels)`` in
    a seeded random order (the order a driver would not guarantee either). Same arithmetic and random stream as
    ``research/frnet/scripts/frnet_preproc_ref.py synthetic_scan`` (cfg = the profile's FOV, ``interp_h`` rows)."""
    rng = np.random.default_rng(seed)
    elev = np.deg2rad(np.linspace(fov_down_deg + 0.05, fov_up_deg - 0.05, rows))
    azim = np.deg2rad(np.arange(n_cols) * (360.0 / n_cols) - 180.0)
    E, A = np.meshgrid(elev, azim, indexing="ij")
    d = np.stack([np.cos(E) * np.cos(A), np.cos(E) * np.sin(A), np.sin(E)], axis=-1).reshape(-1, 3)
    t_best = np.full(len(d), np.inf)
    label = np.full(len(d), -1)
    with np.errstate(divide="ignore", invalid="ignore"):          # ground plane z = -sensor_height
        tg = np.where(d[:, 2] < -1e-6, -sensor_height / d[:, 2], np.inf)
    upd = tg < t_best
    t_best[upd] = tg[upd]
    label[upd] = 0
    boxes = []                                                    # (cx, cy, cz_bottom, lx, ly, lz, class)
    for _ in range(25):                                           # cars
        cx, cy = rng.uniform(-60, 60), rng.choice([-1, 1]) * rng.uniform(3, 12)
        boxes.append((cx, cy, -sensor_height, 4.5, 1.9, 1.6, 5))
    for side in (-1, 1):                                          # building facades
        for k in range(8):
            boxes.append((-80 + 20 * k, side * rng.uniform(18, 25), -sensor_height, 15, 3, rng.uniform(6, 20), 3))
    for _ in range(30):                                           # poles / trees
        boxes.append((rng.uniform(-70, 70), rng.choice([-1, 1]) * rng.uniform(13, 16), -sensor_height, .4, .4, 6, 4))
    for _ in range(10):                                           # pedestrians
        boxes.append((rng.uniform(-30, 30), rng.choice([-1, 1]) * rng.uniform(12, 14), -sensor_height, .6, .6, 1.7,
                      17))
    for (cx, cy, cz, lx, ly, lz, cls) in boxes:
        lo = np.array([cx - lx / 2, cy - ly / 2, cz])
        hi = np.array([cx + lx / 2, cy + ly / 2, cz + lz])
        with np.errstate(divide="ignore", invalid="ignore"):
            inv = 1.0 / d
            t1 = lo * inv
            t2 = hi * inv
        tmin = np.nanmax(np.minimum(t1, t2), axis=1)
        tmax = np.nanmin(np.maximum(t1, t2), axis=1)
        hit = (tmax >= np.maximum(tmin, 0)) & (tmin > 0.5)
        upd = hit & (tmin < t_best)
        t_best[upd] = tmin[upd]
        label[upd] = cls
    ok = np.isfinite(t_best) & (t_best < max_range)
    ok &= rng.random(len(d)) > 0.05                               # beam dropout
    xyz = d[ok] * (t_best[ok, None] + rng.normal(0, 0.02, size=(ok.sum(), 1)))   # range noise
    inten = np.clip(rng.normal(40, 25, size=ok.sum()), 0, 255).round()          # 0..255 intensity
    pts = np.concatenate([xyz, inten[:, None]], axis=1).astype(np.float32)
    perm = rng.permutation(len(pts))
    return pts[perm], label[ok][perm]


def structured(xyzi: np.ndarray) -> np.ndarray:
    pts = np.zeros(len(xyzi), dtype=[("x", "<f4"), ("y", "<f4"), ("z", "<f4"), ("intensity", "<f4")])
    for i, name in enumerate(("x", "y", "z", "intensity")):
        pts[name] = xyzi[:, i]
    return pts


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    ap.add_argument("--weights-dir", default=str(WS / "assets" / "frnet" / "hf_lidar_frnet"))
    ap.add_argument("--threads", type=int, default=4)
    ap.add_argument("--variants", nargs="*", default=list(SENSORS))
    ap.add_argument("--check-against", type=Path, help="directory with synthetic_<v>.npz to compare the points with")
    ap.add_argument("--no-reference", action="store_true", help="write the scans and presets only")
    a = ap.parse_args()
    samples, calib = CODE / "tt_frnet" / "samples", CODE / "tt_frnet" / "calib"
    T = np.eye(4)
    T[2, 3] = SENSOR_HEIGHT
    for variant in a.variants:
        s = SENSORS[variant]
        xyzi, _ = synthetic_scan(s["fov_up_deg"], s["fov_down_deg"], s["rows"], s["cols"], seed=SEED,
                                 sensor_height=SENSOR_HEIGHT)
        pts = structured(xyzi)
        stem = f"synthetic_{variant}"
        if a.check_against is not None:
            old = np.load(a.check_against / f"{stem}.npz", allow_pickle=False)["points"]
            if not (old.dtype == pts.dtype and np.array_equal(old, pts)):
                sys.exit(f"{stem}: the points differ from {a.check_against}")
            print(f"{stem}: identical to {a.check_against / (stem + '.npz')}", flush=True)
        np.savez_compressed(samples / f"{stem}.npz", points=pts)
        preset = {"name": stem,
                  "description": f"synthetic {variant.upper()}-like scan (code/scripts/make_sample.py): the sensor "
                                 f"{SENSOR_HEIGHT:g} m above a flat road, base_link on the road under it (a pure lift)",
                  "source": "code/scripts/make_sample.py (generated, no dataset; Apache-2.0)",
                  "T_base_link_from_sensor": T.tolist()}
        (calib / f"{stem}.json").write_text(json.dumps(preset, indent=1) + "\n")
        print(f"{stem}: {len(pts)} points ({s['rows']} rows x {s['cols']} columns, seed {SEED})", flush=True)
        if a.no_reference:
            continue
        from tt_frnet.io import encode_array
        from tt_frnet.reference.pipeline import FRNetReference

        ref = FRNetReference(a.weights_dir, variant, backend="ort", threads=a.threads)
        run = ref.run(samples / f"{stem}.npz", calibration={"preset": stem})
        if run.prepared.skipped is not None:
            sys.exit(f"{stem}: outside the {variant} profile: {run.prepared.skipped}")
        out = run.output
        body = {k: v for k, v in out.to_dict().items() if k in KEEP}
        body["labels"] = encode_array(out.label_ids, fmt="npz_compressed", key="labels")
        body["scores"] = encode_array(out.scores, fmt="npz_compressed", key="scores")
        body["filter_keep"] = encode_array(out.filter_keep, fmt="npz_compressed", key="filter_keep")
        inp = run.prepared.inputs
        body["meta"] = {"variant": variant, "reference": "onnxruntime fp32 (deployed ONNX)",
                        "onnx_sha256": ref.weights.onnx_sha256, "num_unique_coors": inp.num_voxels,
                        "cropped_by_ego_box": int((~inp.keep_mask).sum()),
                        "generator": f"code/scripts/make_sample.py synthetic_scan({s['rows']} rows, {s['cols']} "
                                     f"columns, seed {SEED})"}
        path = samples / f"{stem}.{variant}.reference.json"
        path.write_text(json.dumps(body) + "\n")
        print(f"{stem}: N {inp.num_points} (raw {inp.num_points_raw}) M {inp.num_voxels} -> {path.name} "
              f"({path.stat().st_size / 1e6:.2f} MB)", flush=True)
    return 0


if __name__ == "__main__":
    sys.exit(main())