File size: 11,876 Bytes
ddaad99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55474c5
 
 
 
 
ddaad99
55474c5
 
 
 
 
 
 
 
 
 
 
 
 
ddaad99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55474c5
 
 
ddaad99
 
 
55474c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ddaad99
 
 
 
 
 
 
 
 
 
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
# Python API: BEVFormer-small (Autoware tensorrt_bevformer) on Blackhole

Use this API from Python code (a pipeline, a notebook, a ROS 2 node wrapper). You do not need the HTTP server: the
API and the server share the decoders, the device graph and the post-processing, so the outputs and the speed are
the same.

## Install

Install the package on top of an environment that already has `ttnn` (a tt-metal `python_env` at `44d66500520`
with `patches/tt-metal-eth-dispatch.patch`, or the tt-model container). From the root of the model repository (the
directory that holds `pyproject.toml`, `README.md` and `code/`):

```bash
pip install -e .                        # the Python API
pip install -e ".[server,test]"         # + the HTTP server and the tests
```

The pip project is the repository's top-level `pyproject.toml`; it installs the package from `code/tt_bevformer` (there is no
`pyproject.toml` inside `code/`, because the container build copies `code/` over the tt-metal tree).

The package carries `tt_bevformer.ttaw`, the shared code of the Autoware ports to Blackhole (device open, trace runner,
decoders, model base class, HTTP app), vendored at the version recorded in `code/tt_bevformer/ttaw/VENDORED.json`.

| You want to run | Extras |
|---|---|
| the Python API | none |
| the HTTP server (`tt_bevformer.server.app`, see `SERVING.md`) | `server` |
| host tests (no device; device tests are skipped): `TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_bevformer/tests` | `server,test` |
| device tests: `python -m pytest -q -s code/tt_bevformer/tests/test_pcc_device.py code/tt_bevformer/tests/test_e2e_device.py` | `test` |

## Quickstart

```python
from tt_bevformer import BEVFormer, load_sample

with BEVFormer.from_pretrained(device_id=0) as model:
    out = model(**load_sample("code/tt_bevformer/samples/synthetic_6cam.json"))   # from the repo root
print(out.to_dict())
```

`load_sample` reads a sample manifest (six image files, a calibration or a preset, the stream with the ego pose) into
the call's keyword arguments. The shipped sample is a synthetic test pattern (`code/tt_bevformer/samples/README.md`);
`examples/quickstart.py` runs it and writes the result and a bird's-eye-view PNG.

## `BEVFormer.from_pretrained(...)`

```python
BEVFormer.from_pretrained(
    model_id=None,          # HF repo or a local directory with the weights files; default changh95/bevformer-small-weights
    *,
    revision=None,          # default for the default repo: the validated commit 0e6d9d9c7b8 (branch main; no tags)
    variant=None,           # load-time variant (default); default $BEVFORMER_VARIANT or "default"
    device_id=None,         # chip to open; default $TT_DEVICE_ID or 0
    device=None,            # an already-opened ttnn device (tt_bevformer.device.open_device); close() does not close it
    dispatch=None,          # "eth" (p150 target, 12x10 grid) | "worker" (A/B only, 11x10) | "auto"; default $BEVFORMER_DISPATCH or "eth"
    num_command_queues=None,  # default $BEVFORMER_NUM_CQS or 1
    weights_dir=None,       # explicit local weights directory; no Hub access
    warmup_variants="default",  # trace variants to capture now; see "Warm-up"
    verbose=False,
    **compile_params,       # shape-defining options (documented per model below)
) -> BEVFormer
```

What it does: resolves the weights first, so a Hub problem never claims the chip (`weights_dir` >
`$BEVFORMER_WEIGHTS_DIR` > a local `model_id` directory > the HF snapshot at the pinned revision, restricted to
`bevformer_small_epoch_24.safetensors, provenance.json`, with an offline fallback to the cache), opens the chip (ETH dispatch, 12×10, 1 CQ;
the other open parameters are `DEVICE_DEFAULTS` in `tt_bevformer/device.py`, overridable with `BEVFORMER_*`), builds the
graph, then compiles and captures the metal trace of every warm-up variant. If ETH dispatch cannot open (tt-metal
without the patch), it warns and falls back to WORKER dispatch.

## Warm-up

`from_pretrained` captures the two trace variants of the frame (`image`: ResNet-101-DCN + FPN + the spatial
cross-attention value maps; `bev`: BEV queries, history, 3 encoder layers, 6 decoder layers, heads), then runs one
synthetic frame through the whole host + device path, so the first 1600×900 call compiles nothing. A camera size other
than 1600×900 (e.g. 1920×1080) compiles its host resize kernel in a background thread on first sight (about 6 s);
until it is ready those calls use the slower generic resize kernel. A new calibration pays its host geometry once
(cached per `lidar2img`). There is one variant (`default`); the steady-state frame (`use_prev_bev == 1`) has its own
`bev` trace. `warmup_variants="none"` skips the captures (the first call then fails: capture explicitly with
`model.warmup()`). The first boot on a machine also pays the ttnn kernel JIT (minutes, cached in
`~/.cache/tt-metal-cache` afterwards); a warm load takes about 10 s (10.4 s measured on 2026-10-13: weights 0.3 s,
graph 1.3 s, warm-up 4.5 s, the rest the device open). The warm-up frame uses the shipped `synthetic_6cam`
calibration preset.

## Precision: `fast` (default) and `accurate`

The load-time `BEVFORMER_*` knobs (read once by `from_pretrained`, from the environment or from keyword arguments
with the knob's lower-case name, e.g. `precision="HiFi4+fp32+l1acc:w=fp32"`) select the precision path. With no
variable set you get the server's default profile `fast` (PLAN D20 reduced precision: HiFi2, bf16 weights, bfp8
layer-3 weights, a bfp8 residual stream in the backbone, flash-SDPA decoder self-attention; judged by task-level
gates on nuScenes mini_val). For the full-precision `accurate` path (fp32 weights, HiFi4, the first release's policy;
the frozen per-stage PCC gates pass) set the 13 variables of the `accurate` serve profile in `tt-model.yaml`
(`serve_profiles`) before loading; SERVING.md section 3.4 lists them. `model.info` reports the knob values in use.

## Call: `model(...)`

| Argument | Type | Description |
|---|---|---|
| `images` | the six cameras | `CAM_FRONT`, `CAM_FRONT_RIGHT`, `CAM_FRONT_LEFT`, `CAM_BACK`, `CAM_BACK_LEFT`, `CAM_BACK_RIGHT` (any order; the network order is baked into its camera embeddings): a list of `tt_bevformer.io.CameraImage` / `{"camera", "image" \| "path" \| "data", "intrinsics", "T_ref_from_camera"}` dicts or a `{name: image}` mapping. Any size: images other than 1600x900 are resized like the node does (uint8 bilinear) |
| `calibration` | dict | per camera `intrinsics` (3x3, of the image as sent) and `T_ref_from_camera` (camera optical frame -> `frame_id`), or `{"preset": "synthetic_6cam"}` (the shipped rig); `frame_id`: `"base_link"` (default; then `T_ref_from_lidar` = the network's LIDAR_TOP -> base_link, default a virtual nuScenes LIDAR_TOP: -90 deg yaw, 1.84 m up) or `"LIDAR_TOP"`; `scale_intrinsics` (default true: K rescaled to 1600x900; false: the node's quirk) |
| `stream` | dict | `{"id", "reset", "timestamp_s", "T_world_from_ego"}`: the BEV history. A new id, `reset: true` or a timestamp gap above `BEVFORMER_MAX_GAP_S` (2 s) starts a fresh history (`use_prev_bev = 0`); `T_world_from_ego` (4x4, default identity) drives the ego-motion compensation (can_bus position / yaw deltas, TSA shift, prev_bev rotation). Up to `BEVFORMER_MAX_STREAMS` (4) ids keep their own device history (least recently used evicted) |
| `ego` | dict | optional CAN dynamics in base_link: `{"accel": [3], "rotation_rate": [3], "velocity": [3]}` (missing = 0; BEVFormer's training layout of `can_bus[7:16]`) |
| `score_threshold` | float [0, 1] | the node's `score_thre` (default 0.2) |
| `max_num` | int [1, 9000] | top-k over the 900 x 10 scores before the threshold (default 300) |
| `physical_boxes` | bool | false (default): the node's message (size `[w, l, h]`, yaw `-rot + pi`); true: size `[l, w, h]`, yaw `-rot - pi/2` (the heading of the length axis) |

### Input types

- Images: a path, PNG/JPEG bytes, a `PIL.Image`, a uint8 HxWx3 array or 3xHxW tensor, or a float array in [0, 1].
- Calibration: a dict in the `/predict` calibration envelope (SERVING.md section 3.1) or `{"preset": name}`
  (`code/tt_bevformer/calib/`); transforms as 4×4 matrices,
  `{translation, rotation_wxyz}` or Autoware `{x, y, z, roll, pitch, yaw}`.

## Output

`tt_bevformer.Output` = `ttaw.outputs.Detections3D` in the node's frame `LIDAR_TOP` (`frame_id`), rows sorted by
score: `boxes` `[N, 7]` (x, y, z gravity centre, size, yaw per `physical_boxes`), `scores`, `label_ids` (network class
0-9: `meta["class_names"]`), `labels` (the Autoware label the node publishes per class: bus, trailer and
construction_vehicle -> TRUCK; barrier, traffic_cone -> UNKNOWN), `velocities` `[N, 2]` (vx, vy in LIDAR_TOP),
`timing_ms` (`preprocess`, `device`, `device_run`, `postprocess`, `total`), `meta` (`class_names`, the box convention,
the raw network angle `rot` and the decoder `query` of every box, `stream` = `{id, fresh, use_prev_bev}`).
`out.to_dict()` is exactly the `/predict` body.

## Lifetime and information

- `model.close()` releases the traces and the device tensors and closes the chip if the model opened it; idempotent.
  `with` calls it for you; an unclosed model is closed when Python exits.
- `model.info`: weights (repo, tag, revision, path), device (dispatch, grid), variant, warm variants, warm-up times,
  runtime parameter defaults.
- Calls from several threads are safe. With `BEVFORMER_PIPELINE=1` (default) one caller's host half overlaps another's
  device stage, frames keep their arrival order; two callers on one stream measured 16.3 frames/s (1600×900) and
  18.1-18.5 frames/s (PandaSet). One model per process per chip.

## Speed

Measured with `code/scripts/bench.py` (ETH dispatch, 1 CQ, 12×10 grid, AICLK 1350 MHz, warm, batch 1; p50 of 30
iterations on a shared 8-core host; 2026-10-13, `VERIFICATION_OPT_2026-10-13.md`, OPT_BASELINE.md "After
optimization"):

| | first release | **`fast` (default)** | `accurate` |
|---|---:|---:|---:|
| `model(...)` end to end, 1600×900 images (synthetic) | 939 ms | **59.0 ms** | 88.9 ms |
| `model(...)` end to end, PandaSet 1920×1080 (decoded before timing): one frame / the f30-35-40 stream | 1,555 / 1,619 ms | **71.0 / 73.6 ms** | 101.9 / 104.2 ms |
| host pre-processing (request, the node's uint8 resize for 1920×1080, staging) | 303 / 919 ms | 3.4 / 12-15 ms | 3.5 / 15 ms |
| device incl. the uint8 image upload and the read-back (`timing_ms.device`, 1600×900) | 633 ms | 54.1 ms | 84.1 ms |
| trace `image` (pre-processing on the device, ResNet-101-DCN + FPN + SCA value maps) / trace `bev` (3 encoder + 6 decoder layers + heads), back to back | 245.0 / 240.5 ms | 34.9 / 14.5 ms | 60.9 / 18.7 ms |
| host post-processing | 1.4 ms | 1.4 ms | 1.4 ms |

Back to back, the two traces take **49.3 ms per frame** (20.3 frames/s device-bound; first release 485.6 ms) with
`fast`, 79.6 ms with `accurate`. `BEVFORMER_NUM_CQS=2` is an A/B option (about +0.9 ms of device time; no gain for
one request at a time). The many other `BEVFORMER_*` knobs are A/B switches of the optimization rounds
(OPT_REPORT.md); the card's numbers use the defaults.

## Limits

- Batch 1 on the chip; one model per process; six cameras in the nuScenes slot order (other rigs need a virtual
  LIDAR_TOP and rescaled K, see `calibration`).
- A rig whose cameras each see more than 6,144 BEV cells is refused with an `InputError` (HTTP 400): the static
  rebatching capacity of the spatial cross-attention (the node's dense graph accepts any rig; every rig tested fits).
- History state for `BEVFORMER_MAX_STREAMS` (4) stream ids; the history advances once per call (the node's cadence)
  and restarts on a new id, `reset` or a timestamp gap above `BEVFORMER_MAX_GAP_S` (2 s).
- Output in the node's LIDAR_TOP frame only; convert with the calibration's `T_ref_from_lidar` (SERVING.md 3.2).