transfusion-p150 / code /PYTHON.md
changh95's picture
tt-model push transfusion-p150 (container)
8dc9457 verified
|
Raw History Blame Contribute Delete
15.7 kB

Python API: TransFusion-L (Autoware lidar_transfusion) 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/):

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_transfusion (there is no pyproject.toml inside code/, because the container build copies code/ over the tt-metal tree).

The package carries tt_transfusion.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_transfusion/ttaw/VENDORED.json.

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

Quickstart

from tt_transfusion import TransFusion

with TransFusion.from_pretrained(device_id=0) as model:
    out = model("code/tt_transfusion/samples/test_pcd.npz")       # Autoware's test.pcd, Apache-2.0
print(out.to_dict())

examples/quickstart.py runs this snippet on the shipped sample and also writes quickstart_bev.png (the points and the boxes from above, tt_transfusion.viz.render_bev).

TransFusion.from_pretrained(...)

TransFusion.from_pretrained(
    model_id=None,          # HF repo or a local directory with the weights files; default AutowareFoundation/lidar_transfusion
    *,
    revision=None,          # default for the default repo: the validated commit 8f07eb93038 (tag v2.1)
    variant=None,           # one network: "default" (also $TRANSFUSION_VARIANT); sampling is a per-call input, not a variant
    device_id=None,         # chip to open; default $TT_DEVICE_ID or 0
    device=None,            # an already-opened ttnn device (tt_transfusion.device.open_device); close() does not close it
    dispatch=None,          # "eth" (p150 target, 12x10 grid) | "worker" (A/B only, 11x10) | "auto"; default $TRANSFUSION_DISPATCH or "eth"
    num_command_queues=None,  # default $TRANSFUSION_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,       # none for this model: the device shapes are fixed (pillar buckets up to 65,536, 768x768 grid, K = 500)
) -> TransFusion

What it does: resolves the weights first, so a Hub problem never claims the chip (weights_dir > $TRANSFUSION_WEIGHTS_DIR > a local model_id directory > the HF snapshot at the pinned revision, restricted to transfusion.onnx, transfusion_ml_package.param.yaml, detection_class_remapper.param.yaml, deploy_metadata.yaml, 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_transfusion/device.py, overridable with TRANSFUSION_*), 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 returns a warm model: it builds the graph, runs every trace variant once eagerly (this first run compiles the kernels and uploads the weights) and captures one metal trace per variant, so no call compiles anything. The variants are pillar-capacity buckets (TRANSFUSION_PFN_BUCKETS, default 8,192, 12,288, 16,384, 20,480, 24,576, 32,768, 40,960, 49,152, plus the full 65,536): a frame runs the trace of the smallest capacity that holds its pillars, with a 768x768 canvas index. The 9 traces share the device weights, and every bucket gives the same outputs as the full 65,536-pillar trace (checked bit for bit on the device on two frames by test_buckets_equal_full_capacity).

  • Measured (2026-10-11, the shipped sample, ETH dispatch, 1 CQ, 12x10): with an empty JIT cache the load took 156.7 s (firmware and every kernel compiled); with that cache warm, in a new process, 17.7 s (weights 0.3 s, graph build 9.8 s including the compile of the numba host twins, eager warm-up and capture of the 9 traces 3.4 s, the rest the device open). The traces are captured during the load, so the first call costs about as much as the next ones (41.3 / 38.5 ms, first / second call, on a shared host).
  • warmup_variants="none" (server: TRANSFUSION_WARMUP=none) skips the capture at load; call model.warmup() before the first frame (it is idempotent).
  • The JIT cache is tt-metal's (~/.cache/tt-metal-cache, or TT_METAL_CACHE); inside the container it is ~/.cache/tt-model/transfusion-p150/cache of the host. A process with WORKER dispatch (11x10) compiles its own kernels.

Call: model(points, **kwargs)

Argument Type Description
points (first positional) path, bytes + fmt=, (N, C) array / tensor, structured array, PointCloud, JSON envelope one LiDAR cloud in base_link with x, y, z, intensity (see "Input types"); a column named time_lag marks Autoware's already densified 2-sweep cloud
sweeps list of {"points", "time_lag_s", "T_current_from_sweep"} client-side densification: the past sweeps, moved into the current frame with Autoware's float32 sweep arithmetic
stream {"id", "timestamp_s", "T_world_from_ego", "reset"} server-side densification exactly as Autoware's PointCloudDensification (densification_num_past_frames 1, per stream id, LRU of 16); a frame without its pose is refused and not cached (Autoware's TF failure)
sampling "clean" (default) | "autoware_compat" | {"mode": "autoware_compat", "offset": int} how the points of a pillar are chosen: clean keeps the first 20 points of each pillar in input order (deterministic); autoware_compat emulates the deployed node's capacity-wide shuffle (a fixed permutation of 2,000,000 slots, seed 0, starting at offset) and its skips of frames with fewer than 5,000 or more than 60,000 pillars (no inference, no objects, meta.skipped)
score_threshold float in [0, 1], default 0.1 strict > on the decoded score; <= 0 means 0.2, as the node's config
circle_nms_dist_threshold float in [0, 10] m, default 0.5 class-agnostic circle NMS between the score-sorted proposals (<= 0 means 0.5 m, as the node)
iou_nms_threshold float in [0, 1], default 0.1 Autoware's IoU-BEV NMS: suppress when the BEV IoU is above it
iou_nms_search_distance_2d float in [0, 200] m, default 10 the IoU-BEV NMS search radius
remap_classes bool, default True Autoware's area class remapper (CAR to TRUCK for 12.1-36 m², CAR / TRUCK / BUS to TRAILER from 36 m²)
max_detections int >= 1, default None (no cap, as Autoware) keep the N best after NMS (not an Autoware knob)

Every knob is host-only (PLAN.md 2.5, RT-host): no device shape changes, and the defaults reproduce Autoware's transfusion.param.yaml and the ml_package exactly (the yaw-norm thresholds are not a knob: the node's values, one per class, 0.3 for CAR, TRUCK, BUS, BICYCLE and 0 for PEDESTRIAN). They are validated before the device runs (a bad value raises tt_transfusion.io.InputError, HTTP 400).

Input types

  • Point clouds: a path (.bin raw float32 rows -- pass fields= if not x, y, z, intensity; .npy; .npz; .pcd ascii / binary / binary_compressed), raw bytes with fmt=, an (N, C) numpy array or torch tensor, a structured array, the JSON envelope of /predict, or tt_transfusion.io.PointCloud. Frame: base_link, metres. An unnamed (N, 4) array is x, y, z, intensity; name the columns (structured array, PointCloud, fields=) for any other layout, e.g. x, y, z, intensity, time_lag.
  • intensity is a network input here (unlike CenterPoint): Autoware's uint8 value, 0-255. Float values are clipped and truncated like a uint8 cast (meta.intensity_changed counts the values that changed); a cloud without an intensity field is refused.
  • Three ways to give Autoware's 2-sweep input: a cloud with a time_lag column (already densified, used as is), sweeps= (densified on the host), or stream= (the node's own state machine). A plain cloud is one sweep.
  • Rows with a non-finite x / y / z are dropped, and so are rows at exactly (0, 0, 0): the missing returns of organized clouds (Autoware's voxelizer would keep such a point).
  • At most 65,536 pillars (a fixed device capacity above Autoware's 60,000; the densest frame seen so far, a 2-sweep frame of Autoware's sample rosbag, has 62,527). Above it the first 65,536 pillars in raster order are kept and meta["pillar_overflow"] is true. 20 points per pillar (see sampling).
  • Images, calibration and planner inputs are refused (a LiDAR-only model).

Output

model(...) returns a tt_transfusion.Output (= ttaw.outputs.Detections3D); out.to_dict() is exactly the POST /predict body (SERVING.md section 3.2).

field type meaning
boxes float32 [N, 7] x, y, z (box centre), length, width, height, yaw in base_link metres / radians, the convention of Autoware's lidar_transfusion node (yaw counter-clockwise from +x, no conversion); sorted by score
scores float32 [N] Autoware's existence_probability (query_heat · σ(heatmap) of the winning class)
label_ids int32 [N] autoware_perception_msgs/ObjectClassification values (CAR 1, TRUCK 2, BUS 3, TRAILER 4, BICYCLE 6, PEDESTRIAN 7); labels holds the names
meta dict variant; num_points, num_points_in_range, num_pillars, num_pillars_total, max_points_in_pillar, pillars_over_cap, pillar_overflow; sampling (mode, offset, the voxelizer's input size); skipped (autoware_compat: Autoware's reason to skip the frame) and autoware_would_skip (the same verdict, reported in clean mode); intensity_changed; densify (mode, points per sweep); counts (proposals, after_threshold, after_circle_nms, after_iou_nms); per detection orientation_availability, label_before_remap and proposal (the top-k slot)
timing_ms dict preprocess, device (staging + H2D + trace + D2H + unpack), postprocess, total of this call

out.to_dicts() gives [{"label", "label_id", "score", "center", "size", "yaw"}]; len(out) the number of detections. No velocity is returned: Autoware ignores the network's velocity rows.

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, cores, num_command_queues, fallback), variant, warm variants, warm-up times, runtime parameter defaults, the config, the pre-processing and the trace descriptions.
  • Calls from several threads are safe: the device calls are serialised by a lock. One model per process per chip.
  • tt_transfusion.viz.render_bev(points, out.to_dicts(), reference=...) draws a bird's-eye view (PIL, no OpenCV).

Speed

Warm calls, batch 1, ETH dispatch, 1 CQ, 12x10, the shipped precision and knob defaults (code/scripts/bench.py, 100 iterations; the independent re-measurement of VERIFICATION_OPT_2026-10-11.md, 2026-10-11, on a shared host whose load moves the host stages):

stage, p50 test.pcd (shipped) PandaSet 019 f40 (not shipped)
model(path) end to end 36.0 ms 71.0 ms
host pre-processing (decode, densify, voxelize, decorate, index) 10.7 ms 36.5 ms
input staging (the kept pillars to bf16, pair-tiled) + H2D 2.7 + 3.5 ms 6.2 + 6.9 ms
device trace, one forward (pillar bucket) 17.23 ms (16,384) 18.11 ms (40,960)
D2H + unpack 0.63 + 0.28 ms 0.63 + 0.29 ms
host post-processing (decode, NMS, remap) 0.73 ms 0.94 ms
  • Back-to-back replays take 17.16 ms per frame on test.pcd (58.3 frames/s of device throughput) and 18.04 ms on the PandaSet frame (55.4 frames/s). A synchronous call gives 27.8 (test.pcd) and 14.1 (PandaSet) calls/s.
  • test.pcd is one sweep of 48,048 points (13,058 pillars); the PandaSet frame two sweeps of 213,007 points (38,158 pillars). The device time grows a little with the pillar bucket; the host stages grow with the cloud.
  • The first release (2026-10-09) took 139.39 ms per back-to-back replay and 211.8 ms per model() call on test.pcd. OPT_REPORT.md has every optimization step with its A/B measurement: among them a two-term pillar feature net and scatter as one custom kernel (a megakernel per pillar bucket), custom epilogue kernels for the two-term convs, the pillar buckets and compiled host stages. All but one step give bit-identical outputs; the two-stage top-k picks a different query on a tie and passes every frozen accuracy gate.

Tests

From the repository root, with code/ on PYTHONPATH (or the package installed):

  • Host (no device, no ttnn call): TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_transfusion/tests. Inside the tt-models workspace the suite runs on its fake ttnn (-p fake_ttnn_plugin), which also enables test_tt_host.py (the TT graph emulated on the CPU against the fp32 reference); tests that need workspace-only oracles or data skip elsewhere, and a missing sample FAILS inside the workspace.
  • Device (one p150): python -m pytest -q -s code/tt_transfusion/tests/test_pcc_device.py code/tt_transfusion/tests/test_e2e_device.py code/tt_transfusion/tests/test_heldout_device.py (per-module PCC and the strong-proposal selection against the fp32 CPU reference on replay outputs, end-to-end agreement on 21 development frames and 34 held-out frames, replay == eager, API == /predict). The public-frame gates run only inside the tt-models workspace, where their data lives: the frames are PandaSet, nuScenes and Argoverse 2 sweeps and Autoware's sample rosbag, which this repository does not ship.
  • The gates are frozen in tests/*.gates.json at the first green run and are never loosened.

Limits

  • Batch 1 on the chip; one model per process; at most 65,536 pillars (a grid-independent capacity above Autoware's 60,000, so no clean-sampled frame seen so far is cut; overflow keeps the first pillars in raster order and is flagged) and 20 points per pillar (the first 20 in input order, instead of Autoware's GPU-atomics order).
  • Outputs differ slightly from the fp32 reference: an object near one of Autoware's decision thresholds (score, yaw-norm gate, NMS, remap area) or at a heatmap near-tie can appear, disappear or come from a neighbouring proposal (README "Caveats" has the measured rates).
  • One p150, ETH dispatch (12x10); dispatch="worker" is an A/B opt-in (11x10). Not a ROS 2 node.