File size: 10,593 Bytes
4d9b003 | 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 | # SPDX-License-Identifier: Apache-2.0
"""Host post-processing (no device, no weights): denormalisation, Eigen's quaternion of the unnormalised rotation,
the trajectory velocity / force-stop / acceleration rules, predicted paths, the turn-indicator decision and the
``make_output`` assembly, against the verified research port (``dp_common.py``), hand-made cases of
``postprocessing_utils.cpp`` / ``turn_indicator_manager.cpp``, and the small goldens of the shipped samples.
TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_diffusion_planner/tests/test_host_postprocess.py
"""
from __future__ import annotations
import math
import numpy as np
import pytest
from tt_diffusion_planner.host import pipeline as hp
from tt_diffusion_planner.host import postprocess as P
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 = R.RESEARCH.parents[1] / "assets" / "diffusion-planner" / "hf_diffusion_planner" / C.PARAM_JSON
DEFAULTS = {k: spec[3] for k, spec in hp.RUNTIME_PARAMS.items()}
@pytest.fixture(scope="module")
def normalization():
if not PARAM_JSON.is_file():
pytest.skip("diffusion_planner.param.json not found")
return load_param_json(PARAM_JSON)
def _straight(n=80, v=8.0, decel_after=None, dt=0.1):
"""A straight ego path at speed v (optionally braking to a stop after ``decel_after`` points)."""
xs, x, speed = [], 0.0, v
for i in range(n):
if decel_after is not None and i >= decel_after:
speed = max(0.0, speed - 1.5)
x += speed * dt
xs.append(x)
p = np.zeros((n, 4), np.float32)
p[:, 0], p[:, 2] = xs, 1.0
return p
# ------------------------------------------------------------------------------------------------- denorm
def test_denormalize_matches_research_port(normalization):
x = np.random.default_rng(0).normal(size=(321, 81, 4)).astype(np.float32)
mean, std = normalization.state()
out = P.denormalize(x, mean, std)
np.testing.assert_array_equal(out, (x[:, 1:] * std + mean).astype(np.float32))
np.testing.assert_array_equal(out[5, 7], x[5, 8] * np.float32([20, 20, 1, 1]) + np.float32([10, 0, 0, 0]))
assert P.denormalize(x, mean, std, keep_current_state=True).shape == (321, 81, 4)
dp = R.load_script("dp_common")
if dp is not None:
sm, ss = np.asarray(normalization.state_mean), np.asarray(normalization.state_std)
np.testing.assert_array_equal(out, dp.denormalize(x[None], (sm, ss))[0])
# --------------------------------------------------------------------------------------------- orientation
def test_quaternion_of_unit_rotations_is_the_half_angle():
th = np.linspace(-math.pi + 1e-3, math.pi - 1e-3, 721)
q = P.quaternion_from_cos_sin(np.cos(th), np.sin(th))
np.testing.assert_allclose(np.abs(q[:, 3]), np.abs(np.cos(th / 2)), atol=1e-12)
np.testing.assert_allclose(np.abs(q[:, 2]), np.abs(np.sin(th / 2)), atol=1e-12)
np.testing.assert_allclose(np.linalg.norm(q, axis=1), 1.0, atol=1e-12)
np.testing.assert_allclose(P.tf2_yaw(q), th, atol=1e-12)
assert not q[:, :2].any()
def test_quaternion_of_unnormalised_rotations_follows_eigen():
"""Eigen does not normalise: |(c, s)| = r != 1 shifts the heading tf2::getYaw reads (SPEC 4.6.6). Its two
branches (trace = 2c + 1 > 0, else the m22 pivot) agree at the switch c = -0.5 only for unit vectors; for
r != 1 the published heading jumps there, a quirk of the node that is reproduced, not fixed."""
r, th = 0.9, math.pi / 2
q = P.quaternion_from_cos_sin(r * math.cos(th), r * math.sin(th))
assert math.isclose(P.tf2_yaw(q), 2 * math.atan(0.9 / (1 + 0.9 * math.cos(th))), rel_tol=1e-12)
assert abs(P.tf2_yaw(q) - th) > 0.05 # not atan2(sin, cos)
eps = 1e-12
for s in (math.sqrt(0.75), -math.sqrt(0.75)): # unit vectors: continuous
a, b = P.quaternion_from_cos_sin(-0.5 + eps, s), P.quaternion_from_cos_sin(-0.5 - eps, s)
np.testing.assert_allclose(P.tf2_yaw(a), P.tf2_yaw(b), atol=1e-9)
# r = 0.58 (c = -0.5, s = 0.3): trace branch w = 0.5, z = 0.3 -> atan2(0.3, 0.16); pivot branch
# z = sqrt(3) / 2, w = 0.3 / sqrt(3) -> atan2(0.3, -0.72)
a, b = P.quaternion_from_cos_sin(-0.5 + eps, 0.3), P.quaternion_from_cos_sin(-0.5 - eps, 0.3)
assert math.isclose(P.tf2_yaw(a), math.atan2(0.3, 0.16), rel_tol=1e-9)
assert math.isclose(P.tf2_yaw(b), math.atan2(0.3, -0.72), rel_tol=1e-9)
# --------------------------------------------------------------------------------------------- trajectory
def test_trajectory_constant_speed():
tr = P.trajectory_from_poses(_straight(v=8.0))
np.testing.assert_allclose(tr.velocity, 8.0, rtol=0, atol=1e-4)
assert tr.velocity.dtype == np.float32 and tr.acceleration.dtype == np.float32
np.testing.assert_allclose(tr.acceleration[:-1], 0.0, atol=2e-3)
assert tr.acceleration[-1] == 0 and not tr.force_stop
np.testing.assert_allclose(tr.time_from_start, 0.1 * np.arange(1, 81))
assert tr.as_columns().shape == (80, 7)
def test_trajectory_force_stop_freezes_poses():
poses = _straight(v=6.0, decel_after=20)
tr = P.trajectory_from_poses(poses, stopping_threshold=0.3)
assert tr.force_stop
stop = int(np.flatnonzero(tr.velocity == 0)[0])
assert np.all(tr.velocity[stop:] == 0)
np.testing.assert_array_equal(tr.position[stop:], np.broadcast_to(tr.position[stop - 1], tr.position[stop:].shape))
off = P.trajectory_from_poses(poses, enable_force_stop=False)
assert not off.force_stop and np.all(off.velocity[-7:] == off.velocity[80 - 8])
with pytest.raises(ValueError, match="velocity_smoothing_window"):
P.trajectory_from_poses(poses, velocity_smoothing_window=80)
@pytest.mark.skipif(R.load_script("dp_common") is None, reason="research scripts not present")
@pytest.mark.parametrize("case", ["cruise", "brake", "golden"])
def test_trajectory_matches_research_port(case):
"""``dp_common.trajectory_from_poses`` (float64 velocities, no float rounding before the average): positions after
freezing identical, velocities within float32 rounding."""
dp = R.load_script("dp_common")
if case == "golden":
p = R.FULL_GOLDENS / "straight_road.npz"
if not p.is_file():
pytest.skip("full goldens not generated")
with np.load(p) as z:
poses = z["out.trajectory"][:, [0, 1, 3, 4]]
else:
poses = _straight(v=7.0, decel_after=15 if case == "brake" else None)
tr = P.trajectory_from_poses(poses)
xy, vel, acc = dp.trajectory_from_poses(poses[:, :2])
np.testing.assert_allclose(tr.position[:, :2], xy, rtol=0, atol=1e-12)
np.testing.assert_allclose(tr.velocity, vel, rtol=1e-6, atol=1e-5)
np.testing.assert_allclose(tr.acceleration, acc, rtol=1e-5, atol=1e-3)
# ------------------------------------------------------------------------------------------ turn indicator
@pytest.mark.skipif(R.load_script("dp_common") is None, reason="research scripts not present")
def test_turn_decision_matches_research_port():
dp = R.load_script("dp_common")
rng = np.random.default_rng(4)
for _ in range(200):
lg = (rng.normal(size=5) * 4).astype(np.float32)
prev = int(rng.integers(1, 4))
d = P.TurnIndicatorManager().evaluate(lg, 0.0, prev)
cmd, prob = dp.turn_indicator_command(lg, prev_report=prev)
assert d.command == cmd
np.testing.assert_allclose(d.probabilities, prob, rtol=1e-6, atol=1e-7)
def test_turn_decision_rules_and_hold():
m = P.TurnIndicatorManager()
keep = np.array([0, 0, 0, 0, 5.0], np.float32) # KEEP wins even after -1.25
d = m.evaluate(keep, 1.0, prev_report=3)
assert (d.command, d.keep_selected) == (3, True) # repeats the last report
left = np.array([0, 0, 4.0, 0, 3.0], np.float32) # 4 > 3 - 1.25: ENABLE_LEFT
d = m.evaluate(left, 2.0, prev_report=1)
assert (d.command, d.keep_selected, d.held) == (2, False, False)
assert math.isclose(sum(d.probabilities), 1.0, rel_tol=1e-3)
d = m.evaluate(keep, 2.9, prev_report=1) # within the 1 s hold window
assert (d.command, d.held) == (2, True)
d = m.evaluate(keep, 3.01, prev_report=1) # expired
assert (d.command, d.held) == (1, False)
tie = np.array([1.0, 1.0, 0, 0, 0], np.float32) # std::max_element: the first maximum
assert P.TurnIndicatorManager().evaluate(tie).command == 0
assert P.TurnIndicatorManager().evaluate(np.zeros(0, np.float32)).command == 1
# ----------------------------------------------------------------------------------------- make_output
@pytest.mark.parametrize("stem", ["kashiwanoha_dense", "straight_road"])
def test_make_output_reproduces_small_goldens(normalization, stem):
"""From the stored reference final x0 / logits, the post-processing gives exactly the stored outputs."""
path = R.SMALL_GOLDENS / f"{stem}.outputs.npz"
if not path.is_file():
pytest.skip("small goldens not generated")
with np.load(path) as z:
g = {k: z[k] for k in z.files}
prep = hp.prepare(R.sample_raw(stem), normalization.observation)
x0 = np.zeros((C.MAX_NUM_AGENTS, C.OUTPUT_T + 1, C.POSE_DIM), np.float32)
x0[g["final_x0.rows"]] = g["final_x0"]
out = hp.make_output(x0, g["logit"], prep, normalization, DEFAULTS, model="diffusion-planner-p150")
np.testing.assert_array_equal(out.poses, g["trajectory"])
assert out.turn_indicator["command"] == int(g["turn_command"])
assert out.columns == hp.TRAJECTORY_COLUMNS and out.predicted_agents.shape == (len(prep.neighbor_rows), 80, 5)
body = out.to_dict()
assert body["num_poses"] == 80 and body["frame_id"] == "base_link"
assert body["predicted_agents"]["shape"] == [len(prep.neighbor_rows), 80, 5]
assert body["meta"]["predicted_agent_rows"] == prep.neighbor_rows.tolist()
# params: a shorter window and no force stop change only the velocity columns
alt = hp.make_output(x0, g["logit"], prep, normalization, {**DEFAULTS, "velocity_smoothing_window": 1})
np.testing.assert_array_equal(alt.poses[:, :5], out.poses[:, :5])
steps = [x0] * 11
dbg = hp.make_output(x0, g["logit"], prep, normalization, {**DEFAULTS, "return_denoising_steps": True},
denoising_steps=steps).to_dict()
assert dbg["meta"]["denoising_steps"]["shape"] == [11, 81, 4]
|