twanghcmut's picture
download
raw
9.26 kB
"""Synthetic, data-free tests for fpgm.geometry."""
from __future__ import annotations
import numpy as np
import pytest
from fpgm.config import ConventionConfig
from fpgm.geometry.camera import Camera
from fpgm.geometry.convention import _bilinear_sample, detect_scene_flow_convention
from fpgm.geometry.transforms import (
assert_valid_se3,
invert_se3,
matrix_to_rpy,
pose6_to_matrix,
pose_error,
rpy_to_matrix,
transform_points,
)
from fpgm.types import (
AmbiguousConventionError,
CameraIntrinsics,
FrameConvention,
GeometryError,
)
def _make_camera(position=(0.3, -0.2, 0.5), rotvec=(0.1, 0.2, -0.15)) -> Camera:
intrinsics = CameraIntrinsics(fx=500.0, fy=480.0, cx=320.0, cy=240.0, width=640, height=480)
world_to_cam = pose6_to_matrix(np.array(position), np.array(rotvec))
return Camera(intrinsics, world_to_cam)
class TestSE3:
def test_invert_se3_roundtrip(self):
transform = pose6_to_matrix(
np.array([1.2, -0.4, 3.1]), np.array([0.3, -0.7, 0.2])
)
inv = invert_se3(transform)
assert np.allclose(transform @ inv, np.eye(4), atol=1e-10)
assert np.allclose(inv @ transform, np.eye(4), atol=1e-10)
def test_transform_points_matches_matrix_multiply(self):
transform = pose6_to_matrix(np.array([1.0, 2.0, 3.0]), np.array([0.1, 0.0, 0.0]))
pts = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [2.0, 3.0, 4.0]])
out = transform_points(transform, pts)
homog = np.concatenate([pts, np.ones((3, 1))], axis=1)
expected = (transform @ homog.T).T[:, :3]
assert np.allclose(out, expected, atol=1e-10)
def test_assert_valid_se3_accepts_identity(self):
assert_valid_se3(np.eye(4))
def test_assert_valid_se3_rejects_non_orthonormal(self):
bad = np.eye(4)
bad[:3, :3] *= 2.0 # scaling breaks orthonormality
with pytest.raises(GeometryError):
assert_valid_se3(bad)
def test_assert_valid_se3_rejects_bad_bottom_row(self):
bad = np.eye(4)
bad[3, :] = [0.0, 0.0, 0.1, 1.0]
with pytest.raises(GeometryError):
assert_valid_se3(bad)
def test_camera_init_rejects_invalid_extrinsic(self):
intrinsics = CameraIntrinsics(fx=1.0, fy=1.0, cx=0.0, cy=0.0, width=10, height=10)
bad = np.eye(4)
bad[:3, :3] *= 3.0
with pytest.raises(GeometryError):
Camera(intrinsics, bad)
class TestPoseError:
def test_zero_at_identical_poses(self):
pose = pose6_to_matrix(np.array([0.3, -0.1, 0.7]), np.array([0.1, 0.2, -0.3]))
err = pose_error(pose, pose)
assert np.allclose(err, np.zeros(6), atol=1e-12)
def test_position_component_matches_direct_difference(self):
current = pose6_to_matrix(np.array([0.0, 0.0, 0.0]), np.zeros(3))
target = pose6_to_matrix(np.array([0.1, -0.2, 0.3]), np.zeros(3))
err = pose_error(current, target)
assert np.allclose(err[:3], [0.1, -0.2, 0.3], atol=1e-10)
assert np.allclose(err[3:], np.zeros(3), atol=1e-10)
def test_rotation_component_recovers_relative_rotvec(self):
current = np.eye(4)
rotvec = np.array([0.0, 0.0, np.pi / 2])
target = pose6_to_matrix(np.zeros(3), rotvec)
err = pose_error(current, target)
assert np.allclose(err[3:], rotvec, atol=1e-10)
class TestRpyConvention:
def test_rpy_to_matrix_reproduces_panda_joint_origins(self):
# panda_joint2: rpy(-pi/2, 0, 0) -> [[1,0,0],[0,0,1],[0,-1,0]] (measured
# against yourdfpy's own parse of the real URDF).
rot = rpy_to_matrix(np.array([-np.pi / 2, 0.0, 0.0]))
expected = np.array([[1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, -1.0, 0.0]])
assert np.allclose(rot, expected, atol=1e-9)
# panda_joint3: rpy(pi/2, 0, 0) -> the transpose-ish opposite.
rot3 = rpy_to_matrix(np.array([np.pi / 2, 0.0, 0.0]))
expected3 = np.array([[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]])
assert np.allclose(rot3, expected3, atol=1e-9)
def test_identity_rpy_is_identity(self):
assert np.allclose(rpy_to_matrix(np.zeros(3)), np.eye(3), atol=1e-12)
def test_matrix_to_rpy_roundtrip(self):
rng = np.random.default_rng(0)
for _ in range(20):
rpy = rng.uniform(-np.pi / 2 + 0.01, np.pi / 2 - 0.01, size=3)
rot = rpy_to_matrix(rpy)
recovered = matrix_to_rpy(rot)
assert np.allclose(rpy_to_matrix(recovered), rot, atol=1e-9)
class TestCameraProjection:
def test_project_unproject_roundtrip(self):
cam = _make_camera()
rng = np.random.default_rng(0)
cam_pts = rng.uniform(low=[-0.5, -0.5, 1.0], high=[0.5, 0.5, 4.0], size=(50, 3))
world_pts = cam.cam_to_world(cam_pts)
uv, depth = cam.project(world_pts)
assert np.allclose(depth, cam_pts[:, 2], atol=1e-8)
recovered = cam.unproject(uv, depth)
assert np.allclose(recovered, world_pts, atol=1e-6)
def test_project_cam_matches_project_via_extrinsic(self):
cam = _make_camera()
rng = np.random.default_rng(1)
cam_pts = rng.uniform(low=[-0.5, -0.5, 1.0], high=[0.5, 0.5, 4.0], size=(20, 3))
world_pts = cam.cam_to_world(cam_pts)
uv_world, depth_world = cam.project(world_pts)
uv_cam, depth_cam = cam.project_cam(cam_pts)
assert np.allclose(uv_world, uv_cam, atol=1e-8)
assert np.allclose(depth_world, depth_cam, atol=1e-8)
def test_project_behind_camera_does_not_raise(self):
cam = _make_camera(position=(0, 0, 0), rotvec=(0, 0, 0))
pts_behind = np.array([[0.0, 0.0, -1.0], [0.1, -0.1, -2.0]])
uv, depth = cam.project(pts_behind)
assert np.all(depth <= 0)
assert np.all(np.isfinite(uv)) # must not raise / must not produce garbage NaNs from a raise
def test_rescaled_projection_is_proportional(self):
cam = _make_camera()
cam2 = cam.rescaled(1280, 960) # exactly 2x
rng = np.random.default_rng(2)
cam_pts = rng.uniform(low=[-0.5, -0.5, 1.0], high=[0.5, 0.5, 4.0], size=(10, 3))
world_pts = cam.cam_to_world(cam_pts)
uv1, depth1 = cam.project(world_pts)
uv2, depth2 = cam2.project(world_pts)
assert np.allclose(uv2, uv1 * 2.0, atol=1e-6)
assert np.allclose(depth1, depth2, atol=1e-8) # depth is metric, resolution-independent
def test_rescaled_does_not_mutate_original(self):
cam = _make_camera()
original_width = cam.K.width
_ = cam.rescaled(100, 100)
assert cam.K.width == original_width
class TestConventionDetection:
def _synthetic_scene(self, rotvec):
"""Build a camera + synthetic image where the ground truth is CAMERA-frame points."""
intrinsics = CameraIntrinsics(fx=100.0, fy=100.0, cx=50.0, cy=50.0, width=100, height=100)
world_to_cam = pose6_to_matrix(np.array([0.0, 0.0, 0.0]), np.array(rotvec))
camera = Camera(intrinsics, world_to_cam)
rng = np.random.default_rng(42)
grid = np.linspace(-0.3, 0.3, 5)
xs, ys = np.meshgrid(grid, grid)
zs = np.full(xs.size, 2.0)
cam_pts = np.stack([xs.ravel(), ys.ravel(), zs], axis=1) # ground truth: CAMERA frame
initial_rgb = rng.integers(0, 256, size=(100, 100, 3)).astype(np.uint8)
uv_true, _ = camera.project_cam(cam_pts)
scene_colors = _bilinear_sample(initial_rgb, uv_true).astype(np.uint8)
return camera, cam_pts, scene_colors, initial_rgb
def test_detects_camera_convention_by_construction(self):
# A 180-degree rotation about X flips the sign of Z for the (wrong) WORLD
# hypothesis, pushing every point behind the camera -- a decisive signal.
camera, cam_pts, scene_colors, initial_rgb = self._synthetic_scene(
rotvec=[np.pi, 0.0, 0.0]
)
cfg = ConventionConfig()
result = detect_scene_flow_convention(camera, cam_pts, scene_colors, initial_rgb, cfg)
assert result.convention == FrameConvention.CAMERA
assert result.margin > cfg.min_margin
assert result.frac_in_bounds > cfg.min_frac_in_bounds
def test_raises_ambiguous_when_hypotheses_tie(self):
# An identity extrinsic makes project() and project_cam() identical, so both
# hypotheses score exactly the same regardless of the scene -- the detector
# must refuse to guess rather than pick one arbitrarily.
intrinsics = CameraIntrinsics(fx=100.0, fy=100.0, cx=50.0, cy=50.0, width=100, height=100)
camera = Camera(intrinsics, np.eye(4))
rng = np.random.default_rng(7)
grid = np.linspace(-0.3, 0.3, 5)
xs, ys = np.meshgrid(grid, grid)
zs = np.full(xs.size, 2.0)
pts = np.stack([xs.ravel(), ys.ravel(), zs], axis=1)
initial_rgb = rng.integers(0, 256, size=(100, 100, 3)).astype(np.uint8)
uv_true, _ = camera.project_cam(pts)
scene_colors = _bilinear_sample(initial_rgb, uv_true).astype(np.uint8)
cfg = ConventionConfig()
with pytest.raises(AmbiguousConventionError):
detect_scene_flow_convention(camera, pts, scene_colors, initial_rgb, cfg)

Xet Storage Details

Size:
9.26 kB
·
Xet hash:
db5ae519a8ad4e77ae7f2ea27f361cfe4dc0519fc8fd90d84fc65690a4d32376

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