File size: 17,737 Bytes
51defdc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""The card's demo media (``media/``) from outputs of this port on the p150. Development tool of the workspace (CPU
only; research venv: numpy, OpenCV, PIL), run after the device jobs that produce the TT outputs:

    PY=/home/ubuntu/experiments/tt-models/tools/research-venv/bin/python
    $PY code/scripts/make_demo.py --tt-public logs/public_tt --tt-sample logs/meteor/shipped_sample_tt.json \\
        [--compare logs/public_tt/compare]

- ``--tt-public``: ``code/scripts/run_public_frames.py``'s output (TT outputs of the public frames in the CPU-golden
  layout). Renders, with the research renderer ``research/meteor/public_data/scripts/render_media.py`` (privacy
  blur, dataset attribution in every footer), the card and the camera-head images of PandaSet 019 frame 40,
  PandaSet 090 frame 40 and nuScenes scene-0103 key-frame 9 (non-commercial, labelled ``_NC``), the PandaSet 019
  animation, and a bird's-eye TT-vs-CPU comparison of PandaSet 019 frame 40 (no camera pixels).
- ``--tt-sample``: the TT ``/predict`` body of the shipped synthetic sample (``tests/test_e2e_device.py`` keeps it);
  renders the eight synthetic cameras with the TT 2D boxes and the TT and CPU-reference bird's-eye views side by side.
- ``media/ATTRIBUTION.md``: every file, its source frames, licence and changes.
"""
from __future__ import annotations

import argparse
import base64
import io
import json
import math
import os
import sys
from pathlib import Path

import numpy as np

BUNDLE = Path(__file__).resolve().parents[2]
ROOT = Path(os.environ.get("TT_MODELS_ROOT", "/home/ubuntu/experiments/tt-models"))
PUBLIC = ROOT / "research" / "meteor" / "public_data"
sys.path.insert(0, str(PUBLIC / "scripts"))
sys.path.insert(0, str(ROOT / "research" / "meteor" / "scripts"))
MEDIA = BUNDLE / "media"
PKG = BUNDLE / "code" / "tt_meteor"
TT_LABEL = "Tenstorrent p150 output: meteor-p150 port (Blackhole, ETH dispatch 12x10, 1 CQ, one metal trace)"
CPU_LABEL = "fp32 CPU reference (the same network, ONNX Runtime parity)"
CARDS = [("ps019", 20), ("ps090", 20), ("ns0103", 9)]


def _rm():
    import render_media as rm  # research renderer (privacy blur, footers)

    return rm


def tag_of(gid: str, i: int, F) -> str:
    return f"meteor_{gid}_f{i:04d}" + ("_NC" if F.dataset == "nuScenes" else "")


def save_jpeg(path: Path, rgb: np.ndarray, q: int = 88) -> None:
    from PIL import Image

    Image.fromarray(rgb).save(path, quality=q, optimize=True)


def save_png(path: Path, rgb: np.ndarray) -> None:
    from PIL import Image

    Image.fromarray(rgb).save(path, optimize=True)


def public_renders(tt_dir: Path, entries: list) -> None:
    rm = _rm()
    for gid, i in CARDS:
        F = rm.Frame(gid, i, str(tt_dir / gid))
        tag = tag_of(gid, i, F)
        lic = "CC BY 4.0 + PandaSet Dataset Terms" if F.dataset == "PandaSet" else "**CC BY-NC-SA 4.0, non-commercial**"
        rgb, nb = rm.card(F, TT_LABEL)
        save_jpeg(MEDIA / f"{tag}_card_tt.jpg", rgb)
        entries.append((f"{tag}_card_tt.jpg", F.dataset, F.desc, lic, "TT outputs: 8-slot camera mosaic with 2D boxes; "
                        "BEV lanes / risk / 3D boxes / futures / plan vs the logged path; front view with projected "
                        "3D boxes and the planned path", rgb.shape))
        hd = rm.heads(F, TT_LABEL)
        save_jpeg(MEDIA / f"{tag}_heads_tt.jpg", hd)
        entries.append((f"{tag}_heads_tt.jpg", F.dataset, F.desc, lic, "TT outputs: per-camera seg2d overlay and "
                        "depth (linear bins), 8 slots", hd.shape))
        print("rendered", tag, flush=True)
    frames = sorted(int(p.stem[1:]) for p in (tt_dir / "ps019").glob("f[0-9][0-9][0-9][0-9].json"))
    p = MEDIA / "meteor_ps019_seq_tt.gif"
    Fs, dur = rm.animation("ps019", frames, str(p), TT_LABEL, golden_dir=str(tt_dir / "ps019"))
    from PIL import Image

    with Image.open(p) as im:
        shape = (im.height, im.width, 3)
    entries.append((p.name, "PandaSet", f"{Fs[0].desc} ... {Fs[-1].desc} (every 4th frame, {len(frames)} frames, "
                    f"{dur} ms each: real time)", "CC BY 4.0 + PandaSet Dataset Terms", "TT outputs: the card as an "
                    "animation (128-colour GIF)", shape))
    print("rendered", p.name, flush=True)


def _text(draw, xy, s, size=12, fill=(235, 235, 235), bold=False, anchor="la"):
    _rm().text(draw, xy, s, size, fill, bold, anchor)


def bev_compare(tt_dir: Path, compare: dict, entries: list) -> None:
    """PandaSet 019 frame 40: CPU BEV | TT BEV | where the two lane maps differ (no camera pixels)."""
    import cv2
    from PIL import Image, ImageDraw

    rm = _rm()
    gid, i = "ps019", 20
    Fc = rm.Frame(gid, i, None)
    Ft = rm.Frame(gid, i, str(tt_dir / gid))
    w, h = 300, 450
    a, b = rm.bev_panel(Fc, w, h), rm.bev_panel(Ft, w, h)
    view_f, view_r, yh = 50.0, 25.0, 25.0
    r0, r1 = int((80 - view_f) / 0.2), int((80 + view_r) / 0.2)
    c0, c1 = int((50 - yh) / 0.2), int((50 + yh) / 0.2)
    lc, lt = Fc.out["lane"][0][r0:r1, c0:c1], Ft.out["lane"][0][r0:r1, c0:c1]
    diff = np.full(lc.shape + (3,), 30, np.uint8)
    diff[lc > 0] = (70, 70, 75)
    diff[lc != lt] = (255, 60, 60)
    diff = cv2.resize(diff, (w, h), interpolation=cv2.INTER_NEAREST)
    H = 44 + 16 + h + 92
    canvas = np.full((H, 3 * w + 4 * 8, 3), rm.BG, np.uint8)
    for k, img in enumerate((a, b, diff)):
        rm.paste(canvas, img, 8 + k * (w + 8), 60)
    im = Image.fromarray(canvas)
    d = ImageDraw.Draw(im)
    _text(d, (8, 6), f"METEOR v1.0 on {Fc.desc}: Tenstorrent p150 vs the fp32 CPU reference (bird's-eye view)", 14, bold=True)
    _text(d, (8, 26), "Pixel-free BEV panels: lane classes, risk heat, 3D boxes, futures, the 3 ego paths (selected: green); "
          "white circles: logged path", 10, fill=rm.TEXT2)
    for k, lab in enumerate(("fp32 CPU reference", "Tenstorrent p150 (this port)", "lane cells that differ (red)")):
        _text(d, (8 + k * (w + 8), 44), lab, 12, bold=True)
    r = compare.get(f"{gid}/f{i:04d}", {})
    lines = []
    if r:
        lines.append(f"Agreement on this frame: lane {r['lane_agree']:.4f}, seg2d {r['seg2d_agree']:.4f}, depth "
                     f"{r['depth_agree']:.4f} (argmax maps); hm / reg / stationary / risk PCC {r['hm_pcc']:.5f} / "
                     f"{r['reg_pcc']:.5f} / {r['stationary_pcc']:.5f} / {r['risk_pcc']:.5f};")
        lines.append(f"3D boxes matched {r['det3d_matched']:.3f} (same class, <= 1 m); selected ego path mean deviation "
                     f"{r['ego_path_dev_m']:.3f} m, same mode: {r['ego_mode_equal']}; traffic light equal: {r['tl_equal']}.")
    lines += ["Contains data from PandaSet (Scale AI and Hesai), https://pandaset.org, CC BY 4.0 + PandaSet Dataset Terms "
              "(outputs only; no camera pixels).", "Scale AI and Hesai do not endorse this work."]
    for k, line in enumerate(lines):
        _text(d, (8, 60 + h + 8 + 15 * k), line, 10, fill=rm.TEXT1 if k < 2 else rm.TEXT2)
    out = np.asarray(im)
    path = MEDIA / "meteor_ps019_f0020_bev_tt_vs_cpu.png"
    save_png(path, out)
    entries.append((path.name, "PandaSet", Fc.desc, "CC BY 4.0 + PandaSet Dataset Terms", "BEV of the fp32 CPU "
                    "reference and of the TT output side by side, and the lane cells that differ (no camera pixels)",
                    out.shape))
    print("rendered", path.name, flush=True)


# ------------------------------------------------------------------------------------------- shipped sample
def _decode_lane(body: dict) -> np.ndarray:
    from PIL import Image

    lane = body["lane"]
    a = np.asarray(Image.open(io.BytesIO(base64.b64decode(lane["data"]))))
    return a.reshape(lane["shape"])


def body_bev(body: dict, w: int, h: int, view_f=50.0, view_r=25.0, yh=25.0) -> np.ndarray:
    import cv2

    rm = _rm()
    lane = _decode_lane(body)
    r0, r1 = int((80 - view_f) / 0.2), int((80 + view_r) / 0.2)
    c0, c1 = int((50 - yh) / 0.2), int((50 + yh) / 0.2)
    img = cv2.resize(np.asarray(rm.LANE_RGB, np.uint8)[lane[r0:r1, c0:c1]], (w, h), interpolation=cv2.INTER_NEAREST)
    s = h / (view_f + view_r)

    def px(x, y):
        return (yh - y) * s, (view_f - x) * s

    for dd in range(-int(view_r // 10) * 10, int(view_f) + 1, 10):
        yy = int(round((view_f - dd) * s))
        cv2.line(img, (0, yy), (w, yy), (55, 55, 60), 1)
        if dd:
            cv2.putText(img, f"{dd}m", (3, yy - 3), cv2.FONT_HERSHEY_SIMPLEX, 0.32, (150, 150, 150), 1, cv2.LINE_AA)
    for det in body["detections"]:
        (x, y), (L, W), yaw = det["center"], det["size"], det["yaw"]
        col = rm.STAT_RGB if det.get("stationary") else (rm.VEH_RGB if det["label_id"] == 0 else rm.VRU_RGB)
        cors = rm.box_corners(x, y, 0, L, W, 0, yaw)[:4]
        p = np.array([px(cx, cy) for cx, cy, _ in cors]).round().astype(np.int32)
        cv2.polylines(img, [p], True, col, 2, cv2.LINE_AA)
        cx, cy = px(x, y)
        fx, fy = px(x + 0.5 * L * math.cos(yaw), y + 0.5 * L * math.sin(yaw))
        cv2.line(img, (int(cx), int(cy)), (int(fx), int(fy)), col, 2, cv2.LINE_AA)
        if det.get("future") and not det.get("stationary") and det["label_id"] == 0:
            q = np.array([[cx, cy]] + [px(a, b) for a, b in det["future"]]).round().astype(np.int32)
            cv2.polylines(img, [q], False, col, 1, cv2.LINE_AA)
    ex, ey = px(0, 0)
    sel = body["plan"]["mode"]
    for k, path in enumerate(body["plan"]["paths"]):
        q = np.array([[ex, ey]] + [px(a, b) for a, b in path]).round().astype(np.int32)
        cv2.polylines(img, [q], False, rm.PLAN_RGB if k == sel else (0, 150, 60), 2 if k == sel else 1, cv2.LINE_AA)
        if k == sel:
            for pt in q[1:]:
                cv2.circle(img, tuple(int(v) for v in pt), 3, rm.PLAN_RGB, -1, cv2.LINE_AA)
    tri = np.array([[ex, ey - 9], [ex - 6, ey + 7], [ex + 6, ey + 7]]).round().astype(np.int32)
    cv2.fillPoly(img, [tri], (255, 255, 255))
    return img


def sample_render(tt_body: dict, entries: list) -> None:
    import cv2
    from PIL import Image, ImageDraw

    rm = _rm()
    ref = json.loads((PKG / "samples" / "synthetic_8cam.reference.json").read_text())
    spec = json.loads((PKG / "samples" / "synthetic_8cam.json").read_text())
    tw, th, gap, x0, top, lab = 233, 131, 6, 4, 44, 14
    bw, bh = 300, 450
    y_bot = top + 2 * (lab + th) + 12
    H = y_bot + 18 + bh + 70
    canvas = np.full((H, 960, 3), rm.BG, np.uint8)
    for k, cam in enumerate(rm.TILE_ORDER):
        img = np.asarray(Image.open(PKG / "samples" / spec["images"][cam]).convert("RGB")).copy()
        for det in tt_body["detections_2d"].get(cam, []):
            a, b, c, e = (int(round(v)) for v in det["box_xyxy"])
            cv2.rectangle(img, (a, b), (c, e), rm.DET10_RGB[det["label_id"] % 10], 2)
        img = cv2.resize(img, (tw, th), interpolation=cv2.INTER_AREA)
        rm.paste(canvas, img, x0 + (k % 4) * (tw + gap), top + (k // 4) * (lab + th) + lab)
    rm.paste(canvas, body_bev(ref, bw, bh), x0, y_bot + 18)
    rm.paste(canvas, body_bev(tt_body, bw, bh), x0 + bw + 8, y_bot + 18)
    im = Image.fromarray(canvas)
    d = ImageDraw.Draw(im)
    _text(d, (8, 6), "METEOR v1.0 on the shipped synthetic sample (samples/synthetic_8cam): Tenstorrent p150 vs the fp32 CPU "
          "reference", 14, bold=True)
    _text(d, (8, 26), "Synthetic test frame generated by this repository (Apache-2.0): a ray-cast street; the object "
          "pixels were optimised against the CPU reference", 10, fill=rm.WARN)
    for k, cam in enumerate(rm.TILE_ORDER):
        n2d = len(tt_body["detections_2d"].get(cam, []))
        _text(d, (x0 + (k % 4) * (tw + gap), top + (k // 4) * (lab + th) + 1), f"{cam[4:]}  (TT 2D boxes: {n2d})",
              10, fill=rm.TEXT2)
    _text(d, (x0, y_bot), "BEV: fp32 CPU reference (stored)", 12, bold=True)
    _text(d, (x0 + bw + 8, y_bot), "BEV: Tenstorrent p150", 12, bold=True)
    ix = x0 + 2 * (bw + 8) + 4
    lines = [f"3D boxes: TT {tt_body['num_detections']}, reference {ref['num_detections']}",
             f"plan mode: TT {tt_body['plan']['mode']} (p={tt_body['plan']['mode_probs'][tt_body['plan']['mode']]:.2f}), "
             f"reference {ref['plan']['mode']}",
             "path end (m): TT ({:.2f}, {:.2f}), reference ({:.2f}, {:.2f})".format(*tt_body["trajectory"][-1],
                                                                                  *ref["trajectory"][-1]),
             f"traffic light: TT {tt_body['traffic_light']['state']}, reference {ref['traffic_light']['state']}",
             f"2D boxes: TT {sum(len(v) for v in tt_body['detections_2d'].values())}, reference "
             f"{sum(len(v) for v in ref['detections_2d'].values())}"]
    lane_t, lane_r = _decode_lane(tt_body), _decode_lane(ref)
    lines.append(f"lane map agreement: {float((lane_t == lane_r).mean()):.4f}")
    by_label: dict = {}
    for det in tt_body["detections"]:
        by_label.setdefault(det["label"], []).append(round(det["score"], 2))
    for k, v in sorted(by_label.items()):
        lines.append(f"TT {k} scores: " + ", ".join(f"{x:.2f}" for x in v))
    for k, line in enumerate(lines):
        _text(d, (ix, y_bot + 22 + 17 * k), line, 11)
    ly = y_bot + 22 + 17 * len(lines) + 10
    for name, colr in (("vehicle", rm.VEH_RGB), ("VRU", rm.VRU_RGB), ("stationary", rm.STAT_RGB), ("plan", rm.PLAN_RGB)):
        d.rectangle([ix, ly + 3, ix + 8, ly + 11], outline=colr, width=2)
        _text(d, (ix + 12, ly), name, 10, fill=rm.TEXT2)
        ly += 15
    _text(d, (8, H - 18), "Data generated by this repository (code/scripts/make_synthetic_sample.py), Apache-2.0. "
          "No third-party pixels.", 10, fill=rm.TEXT2)
    out = np.asarray(im)
    path = MEDIA / "meteor_synthetic_8cam_tt_vs_cpu.png"
    save_png(path, out)
    entries.append((path.name, "synthetic (this repository)", "code/tt_meteor/samples/synthetic_8cam", "Apache-2.0",
                    "the shipped sample's eight cameras with the TT 2D boxes; BEV of the stored CPU reference and of "
                    "the TT output", out.shape))
    print("rendered", path.name, flush=True)


def write_attribution(entries: list) -> None:
    rm = _rm()
    C = rm.C
    lines = ["# media/: sources and licences", "",
             "Every model output drawn here was computed by **this port on one Tenstorrent Blackhole p150** (ETH "
             "dispatch, 12x10 grid, 1 command queue, the `frame` metal trace), unless a panel says \"CPU reference\" "
             "(the port's fp32 CPU reference of the same network, which matches ONNX Runtime on "
             "`meteor_v157c3Z.onnx`). Renderer: `code/scripts/make_demo.py` (camera mosaics, heads and animation through "
             "`research/meteor/public_data/scripts/render_media.py` of the development workspace).", "",
             "METEOR was trained on TIER IV recordings only (no paper, no public training split). PandaSet and nuScenes "
             "are out of its training domain (another camera rig, country and ISP), so the detections show the domain "
             "gap, not the port. No METEOR demo-scene frame appears in any image.", "",
             "| file | data | frames | content | licence | size (px) |", "|---|---|---|---|---|---|"]
    for f, ds, frames, lic, content, shape in entries:
        lines.append(f"| `{f}` | {ds} | {frames} | {content} | {lic} | {shape[1]}x{shape[0]} |")
    lines += ["", "Changes to the dataset images: resized to the 768x432 METEOR inputs (and a centre crop for the "
              "virtual FRONT_NARROW camera), downscaled for display (<= 960 px wide), heads of annotated pedestrians "
              "(<= 30 m) and plate areas of annotated vehicles (<= 25 m) blurred, model outputs drawn. The BACK_NARROW "
              "tile is black because that camera is absent (the model gets a zero image plus the BACK_WIDE pose, its "
              "trained 7-camera configuration).", "",
              "Attribution texts (also printed in each image footer):", "",
              "- PandaSet: " + C.PS_ATTRIBUTION,
              "- nuScenes (files marked `_NC`, **non-commercial**): " + C.NS_ATTRIBUTION,
              "- Synthetic sample: data generated by this repository (`code/scripts/make_synthetic_sample.py`), "
              "Apache-2.0.", "",
              "Rules (research/DATASETS.md section 2): the nuScenes renders are **non-commercial (CC BY-NC-SA 4.0, "
              "ShareAlike)** and keep that label next to the image wherever they are shown; the PandaSet renders are "
              "CC BY 4.0 + the PandaSet Dataset Terms (do not use them to identify people; no use of the licensors' "
              "names or logos beyond the attribution). No raw dataset file is in this directory."]
    (MEDIA / "ATTRIBUTION.md").write_text("\n".join(lines) + "\n")


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--tt-public", type=Path, required=True)
    ap.add_argument("--tt-sample", type=Path, help="TT /predict body of the shipped sample (omit: no sample render)")
    ap.add_argument("--compare", type=Path, help="compare_tt.py JSON files (<id>.json) of the TT run vs the goldens")
    a = ap.parse_args()
    MEDIA.mkdir(exist_ok=True)
    compare = {}
    if a.compare:
        for p in sorted(a.compare.glob("*.json")):
            for r in json.loads(p.read_text()).get("rows", []):
                compare[f"{p.stem}/f{r['frame']:04d}"] = r
    entries: list = []
    public_renders(a.tt_public, entries)
    bev_compare(a.tt_public, compare, entries)
    if a.tt_sample:
        sample_render(json.loads(a.tt_sample.read_text()), entries)
    write_attribution(entries)


if __name__ == "__main__":
    main()