twanghcmut/backup-foundation-physics / tests /test_physics_likelihood.py
twanghcmut's picture
download
raw
10.1 kB
"""Tests for :mod:`fpgm.physics.likelihood`: residuals and the Student-t log-likelihood.
GPU-free, no downloads, no mujoco: every observation and rollout here is
synthetic, built directly from :class:`~fpgm.physics.types.ObservedTrack` and
hand-constructed ``(N, T, 7)`` predicted-pose arrays.
"""
from __future__ import annotations
import numpy as np
from fpgm.physics.likelihood import log_likelihood, se3_residuals
from fpgm.physics.types import ObservedTrack
# --------------------------------------------------------------------------- #
# Local quaternion helpers, independent of anything in fpgm.physics.likelihood
# -- so a bug in the module under test can't hide itself from these tests.
# --------------------------------------------------------------------------- #
def _quat_to_matrix(q: np.ndarray) -> np.ndarray:
w, x, y, z = q
n = w * w + x * x + y * y + z * z
s = 2.0 / n
wx, wy, wz = s * w * x, s * w * y, s * w * z
xx, xy, xz = s * x * x, s * x * y, s * x * z
yy, yz, zz = s * y * y, s * y * z, s * z * z
return np.array(
[
[1.0 - (yy + zz), xy - wz, xz + wy],
[xy + wz, 1.0 - (xx + zz), yz - wx],
[xz - wy, yz + wx, 1.0 - (xx + yy)],
]
)
def _random_quat(rng: np.random.Generator) -> np.ndarray:
axis = rng.normal(size=3)
axis /= np.linalg.norm(axis)
angle = rng.uniform(-0.6, 0.6)
q = np.array([np.cos(angle / 2.0), *(axis * np.sin(angle / 2.0))])
return q / np.linalg.norm(q)
def _make_track(n_frames=12, valid=None, seed=0, sigma_trans=0.005, sigma_rot=0.02):
rng = np.random.default_rng(seed)
T_world_obj = np.zeros((n_frames, 4, 4))
quats = np.zeros((n_frames, 4))
for t in range(n_frames):
pos = np.array([0.3, 0.0, 0.1]) + rng.normal(size=3) * 0.05
q = _random_quat(rng)
quats[t] = q
T = np.eye(4)
T[:3, :3] = _quat_to_matrix(q)
T[:3, 3] = pos
T_world_obj[t] = T
valid_arr = np.ones(n_frames, dtype=bool) if valid is None else np.asarray(valid, dtype=bool)
track = ObservedTrack(
uuid="ep0",
camera_serial="cam0",
label="obj",
T_world_obj=T_world_obj,
valid=valid_arr,
sigma_trans_m=sigma_trans,
sigma_rot_rad=sigma_rot,
)
return track, quats
def _predicted_from_track(
track: ObservedTrack, quats: np.ndarray, n_particles: int = 1
) -> np.ndarray:
pos = track.T_world_obj[:, :3, 3]
base = np.concatenate([pos, quats], axis=-1) # (T, 7)
return np.tile(base[None, :, :], (n_particles, 1, 1))
def _gaussian_logpdf_sum(residuals: np.ndarray, sigma: float) -> float:
"""Independent re-implementation of a Gaussian log-density sum, for the outlier test.
Deliberately not calling anything in ``fpgm.physics.likelihood`` -- this is
the "what a Gaussian would have done" baseline the Student-t result is
compared against.
"""
z = residuals / sigma
return float(np.sum(-0.5 * z * z))
# --------------------------------------------------------------------------- #
# Identity checks
# --------------------------------------------------------------------------- #
def test_identity_residuals_are_zero():
track, quats = _make_track()
predicted = _predicted_from_track(track, quats)
trans_res, rot_res = se3_residuals(track, predicted)
assert np.allclose(trans_res, 0.0, atol=1e-12)
assert np.allclose(rot_res, 0.0, atol=1e-8)
def test_identity_likelihood_is_max_over_sweep():
track, quats = _make_track()
rng = np.random.default_rng(1)
n_frames = track.T_world_obj.shape[0]
n_perturb = 6
predicted = np.zeros((1 + n_perturb, n_frames, 7))
predicted[0] = _predicted_from_track(track, quats, n_particles=1)[0]
for i in range(1, 1 + n_perturb):
pos = track.T_world_obj[:, :3, 3] + rng.normal(size=(n_frames, 3)) * 0.01 * i
perturbed_quats = quats + rng.normal(size=quats.shape) * 0.02 * i
perturbed_quats /= np.linalg.norm(perturbed_quats, axis=-1, keepdims=True)
predicted[i] = np.concatenate([pos, perturbed_quats], axis=-1)
ok = np.ones(predicted.shape[0], dtype=bool)
ll = log_likelihood(track, predicted, ok)
assert np.argmax(ll) == 0
assert np.all(ll[0] >= ll[1:])
# --------------------------------------------------------------------------- #
# Invalid frames
# --------------------------------------------------------------------------- #
def test_invalid_frames_never_contribute():
n_frames = 12
valid = np.ones(n_frames, dtype=bool)
valid[3] = False
valid[7] = False
track, quats = _make_track(n_frames=n_frames, valid=valid)
rng = np.random.default_rng(2)
predicted = _predicted_from_track(track, quats, n_particles=4)
predicted = predicted + rng.normal(size=predicted.shape) * 0.01
predicted[..., 3:7] /= np.linalg.norm(predicted[..., 3:7], axis=-1, keepdims=True)
ok = np.array([True, True, False, True])
trans_res, rot_res = se3_residuals(track, predicted)
ll = log_likelihood(track, predicted, ok)
garbage = predicted.copy()
garbage[:, 3, :3] = 1e6
garbage[:, 3, 3:7] = np.array([1e6, 2e6, -3e6, 4e6])
garbage[:, 7, :3] = -1e6
garbage[:, 7, 3:7] = np.array([5e6, 0.0, 0.0, 0.0])
trans_res2, rot_res2 = se3_residuals(track, garbage)
ll2 = log_likelihood(track, garbage, ok)
assert np.array_equal(trans_res, trans_res2, equal_nan=True)
assert np.array_equal(rot_res, rot_res2, equal_nan=True)
assert np.array_equal(ll, ll2)
assert np.all(np.isnan(trans_res[:, 3])) and np.all(np.isnan(trans_res[:, 7]))
assert np.all(np.isnan(rot_res[:, 3])) and np.all(np.isnan(rot_res[:, 7]))
def test_invalid_frames_are_nan_not_zero():
n_frames = 5
valid = np.ones(n_frames, dtype=bool)
valid[2] = False
track, quats = _make_track(n_frames=n_frames, valid=valid)
predicted = _predicted_from_track(track, quats)
trans_res, rot_res = se3_residuals(track, predicted)
assert np.isnan(trans_res[0, 2])
assert np.isnan(rot_res[0, 2])
assert np.all(np.isfinite(trans_res[0, valid]))
assert np.all(np.isfinite(rot_res[0, valid]))
# --------------------------------------------------------------------------- #
# Geodesic rotation angle
# --------------------------------------------------------------------------- #
def test_rotation_residual_is_true_geodesic_angle():
T = np.eye(4)
track = ObservedTrack(
uuid="ep0",
camera_serial="cam0",
label="obj",
T_world_obj=T[None, :, :],
valid=np.array([True]),
sigma_trans_m=0.01,
sigma_rot_rad=0.01,
)
q90 = np.array([np.cos(np.pi / 4.0), 0.0, 0.0, np.sin(np.pi / 4.0)])
q180 = np.array([0.0, 0.0, 0.0, 1.0])
predicted = np.zeros((2, 1, 7))
predicted[0, 0, 3:7] = q90
predicted[1, 0, 3:7] = q180
_, rot_res = se3_residuals(track, predicted)
assert np.isclose(rot_res[0, 0], np.pi / 2.0, atol=1e-8)
assert np.isclose(rot_res[1, 0], np.pi, atol=1e-8)
# --------------------------------------------------------------------------- #
# n_valid == 0
# --------------------------------------------------------------------------- #
def test_zero_valid_frames_returns_exact_zeros():
track, quats = _make_track(valid=np.zeros(12, dtype=bool))
predicted = _predicted_from_track(track, quats, n_particles=5)
ok = np.ones(5, dtype=bool)
ll = log_likelihood(track, predicted, ok)
assert np.array_equal(ll, np.zeros(5))
# Even a wildly different rollout changes nothing: with no valid frames,
# log_likelihood returns before ever looking at predicted_poses's values.
ll2 = log_likelihood(track, predicted + 100.0, ok)
assert np.array_equal(ll2, np.zeros(5))
# --------------------------------------------------------------------------- #
# Student-t vs Gaussian on an outlier frame
# --------------------------------------------------------------------------- #
def test_student_t_downweights_outlier_relative_to_gaussian():
"""One outlier frame should hurt far less under our Student-t than under a Gaussian.
Particle A matches 5 of 6 frames exactly and misses the 6th by 10 sigma
(one bad PnP frame). Particle B misses every one of 6 frames by a modest
1.2 sigma (a consistently slightly-off rollout). Under a Gaussian, A's
single 10-sigma residual contributes -50 nats (before B's -4.32) --
A is crushed by one frame. Under Student-t(nu=4), that same 10-sigma frame
only costs about -8.1 nats, so the A-vs-B gap shrinks by roughly an order
of magnitude -- an outlier frame no longer single-handedly vetoes a
particle that fits everything else well.
"""
n_frames = 6
T_world_obj = np.tile(np.eye(4)[None, :, :], (n_frames, 1, 1))
track = ObservedTrack(
uuid="ep0",
camera_serial="cam0",
label="obj",
T_world_obj=T_world_obj,
valid=np.ones(n_frames, dtype=bool),
sigma_trans_m=1.0,
sigma_rot_rad=1.0, # rotation residual is 0 for both particles; term cancels in the gap
)
delta_a = np.array([0.0, 0.0, 0.0, 0.0, 0.0, 10.0])
delta_b = np.full(n_frames, 1.2)
predicted = np.zeros((2, n_frames, 7))
predicted[:, :, 3] = 1.0 # identity quaternion (w=1) for both particles, all frames
predicted[0, :, 0] = delta_a
predicted[1, :, 0] = delta_b
ok = np.ones(2, dtype=bool)
trans_res, rot_res = se3_residuals(track, predicted)
# rotation term is identical (zero) for A and B, so it drops out of the gap below
assert np.allclose(rot_res, 0.0, atol=1e-12)
ll = log_likelihood(track, predicted, ok, nu=4.0)
t_gap = float(ll[1] - ll[0]) # B - A under Student-t
gaussian_gap = _gaussian_logpdf_sum(delta_b, 1.0) - _gaussian_logpdf_sum(delta_a, 1.0)
assert gaussian_gap > 40.0 # sanity: the Gaussian baseline really is dominated by the outlier
assert t_gap > 0.0 # B is still favoured under Student-t too
assert abs(t_gap) < 0.3 * abs(gaussian_gap) # but by far less: the outlier's veto is softened

Xet Storage Details

Size:
10.1 kB
·
Xet hash:
ae9bc454e2cbe2d5271d365763ce9cda3e92a429b2ce7baeddfbbcc5674e56c3

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.