# SPDX-License-Identifier: Apache-2.0 """Host pre-processing (no device, no weights): the node's normalization rule and the encoder's in-graph pre-processing as host features, against the verified research reference (``dp_common.py``) and against the rules of ``preprocessing_utils.cpp:34-84`` / SPEC 3.8 on hand-made cases. (The features are proven against the exported graph itself, tensor by tensor, in ``test_reference_cpu.py``.) TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_diffusion_planner/tests/test_host_preprocess.py """ from __future__ import annotations import json import math import numpy as np import pytest from tt_diffusion_planner.host.features import atan2_onnx, decoder_masks, encoder_features, line_features from tt_diffusion_planner.host.normalize import FLT_EPSILON, normalize_array, normalize_inputs, speed_masks from tt_diffusion_planner.host.pipeline import prepare from tt_diffusion_planner.reference import config as C from tt_diffusion_planner.reference.weights import load_param_json from tt_diffusion_planner.tests import _research as R PARAM_JSON = None for _cand in (R.RESEARCH.parents[1] / "assets" / "diffusion-planner" / "hf_diffusion_planner" / C.PARAM_JSON,): if _cand.is_file(): PARAM_JSON = _cand @pytest.fixture(scope="module") def normalization(): if PARAM_JSON is None: pytest.skip("diffusion_planner.param.json not found") return load_param_json(PARAM_JSON) def _zero_raw(): raw = {k: np.zeros(s, np.float32) for k, s in C.INPUT_SHAPES.items()} raw["ego_current_state"][0, :4] = (0, 0, 1, 0) raw["ego_agent_past"][0, :, 2] = 1.0 raw["turn_indicators"][:] = 1.0 raw["ego_shape"][0] = (2.79, 4.89, 1.896) return raw # ------------------------------------------------------------------------------------------- normalization def test_normalize_rule_zero_rows_and_columns(): mean = np.array([10.0, 0.0, 0.0, 0.0], np.float32) std = np.array([20.0, 20.0, 1.0, 1.0], np.float32) v = np.array([[0, 0, 0, 0], # padding: untouched [1e-8, -1e-8, 0, 1e-9], # every |v| < FLT_EPSILON: untouched (kept as is) [30.0, 2.0, 1.0, 0.0], # a real row [0, 0, 1.0, 0]], np.float32) # cos = 1 makes the row real: x -> -0.5 out = normalize_array(v, mean, std) np.testing.assert_array_equal(out[0], v[0]) np.testing.assert_array_equal(out[1], v[1]) np.testing.assert_array_equal(out[2], ((v[2] - mean) / std).astype(np.float32)) np.testing.assert_array_equal(out[3], np.array([-0.5, 0, 1, 0], np.float32)) one = normalize_array(np.array([[0.0], [10.0], [-FLT_EPSILON]], np.float32), np.array([0.0]), np.array([20.0])) np.testing.assert_array_equal(one.ravel(), np.array([0.0, 0.5, -FLT_EPSILON / 20], np.float32)) with pytest.raises(ValueError, match="Standard deviation is zero"): normalize_array(v, mean, np.array([20, 20, 0, 1], np.float32)) with pytest.raises(KeyError, match="Missing key lanes"): normalize_inputs({"lanes": np.zeros(C.INPUT_SHAPES["lanes"], np.float32)}, {}) def test_normalize_skips_raw_keys(normalization): raw = _zero_raw() raw["sampled_trajectories"][0, 0, 5] = (1, 2, 3, 4) norm = normalize_inputs(raw, normalization.observation) for k in C.SKIP_NORMALIZATION: np.testing.assert_array_equal(norm[k], raw[k]) assert norm["ego_agent_past"][0, 0].tolist() == [-0.5, 0.0, 1.0, 0.0] @pytest.mark.skipif(R.load_script("dp_common") is None, reason="research scripts not present") @pytest.mark.parametrize("scene", R.research_scenes() or ["none"]) def test_normalization_and_masks_equal_research_reference(normalization, scene): dp = R.load_script("dp_common") raw = R.research_scene_raw(scene) if raw is None: pytest.skip("no research scene") _, obs, _ = dp.load_param_json(str(PARAM_JSON)) want = dp.normalize_inputs(raw, obs) got = normalize_inputs(raw, normalization.observation) for k in want: np.testing.assert_array_equal(got[k], want[k], err_msg=k) for k, v in dp.speed_masks(want).items(): np.testing.assert_array_equal(speed_masks(got)[k], v, err_msg=k) with np.load(R.ORT_GOLDENS / f"golden_{scene}.npz") as z: # what the ORT goldens were fed for k in C.ENCODER_INPUTS: src = got[k] if k in got else speed_masks(got)[k] np.testing.assert_array_equal(src, z["in/" + k], err_msg=k) # ------------------------------------------------------------------------------------------------- features def test_atan2_onnx_quadrants_and_quirks(): rng = np.random.default_rng(0) y = rng.normal(size=1000).astype(np.float32) x = rng.normal(size=1000).astype(np.float32) np.testing.assert_allclose(atan2_onnx(y, x), np.arctan2(y, x), rtol=0, atol=2e-6) # x = 0: dy / 0 = +-inf -> +-pi/2 like atan2 np.testing.assert_allclose(atan2_onnx(np.float32([2.0, -3.0]), np.float32([0.0, 0.0])), [math.pi / 2, -math.pi / 2], atol=1e-7) # the exported decomposition maps y = 0, x < 0 to -pi (IEEE atan2 gives +pi); 0 / 0 is NaN (masked later) assert atan2_onnx(np.float32(0.0), np.float32(-1.0)) == np.float32(-3.1415927) assert np.isnan(atan2_onnx(np.float32(0.0), np.float32(0.0))) def test_line_features_append_point_deltas(): x = np.zeros((2, 4, 3), np.float32) x[0, :, 0] = [0, 1, 3, 6] x[0, :, 1] = [0, -1, -1, 2] x[0, :, 2] = 1.0 f = line_features(x) assert f.shape == (2, 4, 5) np.testing.assert_array_equal(f[0, :, 3], [1, 2, 3, 0]) np.testing.assert_array_equal(f[0, :, 4], [-1, 0, 3, 0]) np.testing.assert_array_equal(f[1], 0) def test_encoder_features_rules(normalization): raw = _zero_raw() raw["ego_agent_past"][0, :, 0] = np.arange(31) - 30.0 # x: -30 .. 0 nb = raw["neighbor_agents_past"][0] nb[0, :, :4] = (12.0, 3.0, 1.0, 0.0) # agent 0: valid everywhere nb[0, :, 4:8] = (5.0, 1.0, 1.8, 4.5) nb[0, :, 8] = 1.0 nb[1, 10, 0] = 50.0 # agent 1: only an old (dropped) row nb[2, 30, 4] = 2.0 # agent 2: only a velocity at t = 30 nb[3, 27, :4] = (20.0, 1.0, 0.0, 1.0) # agent 3: a pose at t = 27 only raw["lanes"][0, 0, :, 0] = np.linspace(0, 19, 20) # lane 0 along +x raw["lanes"][0, 0, :19, 2] = 1.0 raw["lanes"][0, 0, 0, 12] = 1.0 # no traffic light (dim 12) raw["lanes_speed_limit"][0, 0, 0] = 8.0 raw["polygons"][0, 0, :, 0] = np.linspace(0, 39, 40) raw["polygons"][0, 0, :, 2] = 1.0 raw["line_strings"][0, 1, :, 1] = np.linspace(5, 24, 20) raw["line_strings"][0, 1, :, 3] = 1.0 # road border norm = normalize_inputs(raw, normalization.observation) norm.update(speed_masks(norm)) f = encoder_features(norm, norm) # ego: the 6 OLDEST rows kept, the rest zeroed; the token is valid with an all-zero position np.testing.assert_array_equal(f.ego[:6], norm["ego_agent_past"][0, :6]) assert not f.ego[6:].any() and f.valid["ego"].all() and not f.pos[0, :4].any() and f.pos[0, 4] == 1 # neighbours: rows 0..24 dropped; agent 1 (old row only) invalid; agent 2's t = 30 row is non-zero (a velocity), # so the normalisation moved its x to (0 - 10) / 20 = -0.5; the velocity is zeroed after the validity test and # the valid-step flag set assert f.valid["neighbor"][:4].tolist() == [True, False, True, True] assert not f.neighbor[:, :25].any() np.testing.assert_array_equal(f.neighbor[0, 25:, 4:6], 0) np.testing.assert_array_equal(f.neighbor[0, 25:, 8], 1) np.testing.assert_array_equal(f.neighbor[2, 30], [-0.5, 0, 0, 0, 0, 0, 0, 0, 1]) np.testing.assert_array_equal(f.neighbor[3, :, 8], (np.arange(31) == 27).astype(np.float32)) np.testing.assert_array_equal(f.neighbor_type[0], [1, 0, 0]) sl = C.TOKEN_SLICES np.testing.assert_allclose(f.pos[sl["neighbor"].start, :4], [0.1, 0.15, 1.0, 0.0], atol=1e-7) # lanes: heading of point 10 from (dx, dy); speed and its TRT mask; attributes from point 0 assert f.valid["lane"][0] and not f.valid["lane"][1:].any() np.testing.assert_allclose(f.pos[sl["lane"].start, :4], [norm["lanes"][0, 0, 10, 0], 0, 1, 0], atol=1e-7) assert f.lane_has_speed[0, 0] and not f.lane_has_speed[1:].any() and f.lane_speed[0, 0] == np.float32(0.4) assert f.lane_attr[0, 12 - C.LANE_FEATURE_DIM] == 1 # polygons: the pseudo-heading atan2(dx, is_intersection_area) of point 20 (dx = 1/20 normalised) h = math.atan2(1.0 / 20.0, 1.0) np.testing.assert_allclose(f.pos[sl["polygon"].start, 2:4], [math.cos(h), math.sin(h)], atol=1e-6) # line strings: atan2(is_road_border, is_stop_line) = pi/2 for a road border, whatever its geometry np.testing.assert_allclose(f.pos[sl["line_string"].start + 1, 2:4], [0, 1], atol=1e-6) assert not f.valid["line_string"][0] and f.valid["line_string"][1] # static objects are zeros in Autoware: never valid; goal / ego shape / turn always valid assert not f.valid["static"].any() and f.valid["goal"].all() and f.valid["turn"].all() np.testing.assert_array_equal(f.pos[sl["turn"].start, :4 + 9], [0, 0, 1, 0] + [0] * 8 + [1]) np.testing.assert_array_equal(f.turn, raw["turn_indicators"][0, :30]) # masks: invalid tokens have no position feature; the ego key is always valid assert not f.pos[~f.token_valid].any() assert f.key_valid[0] and (f.key_valid[1:] == f.token_valid[1:]).all() assert f.token_valid.sum() == 1 + 3 + 1 + 1 + 1 + 3 # the decoder keys on the CURRENT (normalised) pose only (dit.py:155-156): agent 3, whose only pose is at t = 27, # is an encoder entity but not a decoder key; agent 2's normalised x = -0.5 makes it a key d = decoder_masks(norm) assert d.agent_valid[:5].tolist() == [True, True, False, True, False] np.testing.assert_array_equal(d.current_states[0], [-0.5, 0, 1, 0]) np.testing.assert_array_equal(d.current_states[1], norm["neighbor_agents_past"][0, 0, 30, :4]) def test_prepare_on_the_shipped_samples(normalization): for stem, n_agents in (("kashiwanoha_dense", 88), ("straight_road", 12)): raw = R.sample_raw(stem) p = prepare(raw, normalization.observation) assert p.neighbor_rows.tolist() == list(range(n_agents)) assert p.enable_force_stop and p.prev_report == 1 assert p.features.valid["neighbor"].sum() == n_agents == p.decoder.agent_valid.sum() - 1 np.testing.assert_array_equal(p.x_T, raw["sampled_trajectories"][0]) assert set(p.norm) >= set(C.INPUT_NAMES) | {"lanes_has_speed_limit", "route_lanes_has_speed_limit"} def test_prepare_refuses_bad_inputs(normalization): from tt_diffusion_planner.ttaw.io import InputError raw = _zero_raw() del raw["delay"] with pytest.raises(InputError, match="missing"): prepare(raw, normalization.observation) raw = _zero_raw() raw["lanes"] = np.zeros((1, 70, 20, 33), np.float32) with pytest.raises(InputError, match="shape"): prepare(raw, normalization.observation) def test_param_json_contract(normalization): assert normalization.major_version == 5 mean, std = normalization.state() assert mean.shape == (C.MAX_NUM_AGENTS, 1, 4) np.testing.assert_array_equal(np.unique(mean.reshape(-1, 4), axis=0), [[10, 0, 0, 0]]) np.testing.assert_array_equal(np.unique(std.reshape(-1, 4), axis=0), [[20, 20, 1, 1]]) assert set(normalization.observation) == set(C.INPUT_NAMES) - set(C.SKIP_NORMALIZATION) args = normalization.args assert (args["agent_num"], args["time_len"], args["future_len"], args["hidden_dim"]) == (320, 31, 80, 256) assert (args["encoder_mixer_depth"], args["encoder_fusion_depth"], args["decoder_depth"]) == (6, 6, 3) assert json.loads(json.dumps(args))["diffusion_model_type"] == "x_start"