twanghcmut's picture
download
raw
5.67 kB
"""Synthetic, data-free tests for fpgm.geometry.normals."""
from __future__ import annotations
import numpy as np
import pytest
from fpgm.geometry.normals import decode_normals_rgb, encode_normals_rgb, normals_from_depth
from fpgm.types import GeometryError
_FX = _FY = 100.0
_CX = _CY = 50.0
_H = _W = 101
def _K() -> np.ndarray:
return np.array([[_FX, 0.0, _CX], [0.0, _FY, _CY], [0.0, 0.0, 1.0]])
class TestFrontoParallelPlane:
def test_normal_is_minus_z(self):
"""Sign convention: a plane facing the camera recovers normal (0, 0, -1).
Chosen because this codebase's cameras follow the OpenCV convention
where depth increases away from the camera along +Z (see
``Camera.project_cam``), so the direction pointing back toward the
camera -- i.e. "facing the viewer" -- is -Z.
"""
depth = np.full((_H, _W), 2.0, dtype=np.float32)
normals = normals_from_depth(depth, _K())
interior = normals[10:-10, 10:-10]
expected = np.array([0.0, 0.0, -1.0], dtype=np.float32)
assert np.allclose(interior, expected, atol=1e-6)
class TestTiltedPlane:
def test_recovered_normal_matches_analytic_plane(self):
"""A known-tilted plane's depth map is generated analytically and inverted.
The plane is defined by an implicit equation ``coef . P = d0`` in the
camera frame, with ``coef`` chosen as ``-n_expected`` so that
``n_expected`` is exactly what :func:`TestFrontoParallelPlane`'s sign
convention predicts should come back out (the fronto-parallel case is
the ``coef = (0, 0, 1)``, ``n_expected = (0, 0, -1)`` special case of
this same construction).
"""
n_expected = np.array([0.3, 0.2, -0.9])
n_expected /= np.linalg.norm(n_expected)
coef = -n_expected
d0 = 2.0
vv, uu = np.mgrid[0:_H, 0:_W].astype(np.float64)
denom = coef[0] * (uu - _CX) / _FX + coef[1] * (vv - _CY) / _FY + coef[2]
depth = d0 / denom
assert np.all(depth > 0), "test construction error: plane must stay in front of the camera"
normals = normals_from_depth(depth, _K())
interior = normals[10:-10, 10:-10]
err = np.linalg.norm(interior - n_expected.astype(np.float32), axis=-1)
assert err.max() < 1e-3
class TestInvalidHandling:
def test_zero_depth_pixel_and_neighbours_are_zero_not_nan(self):
depth = np.full((_H, _W), 2.0, dtype=np.float32)
depth[50, 50] = 0.0 # a single dropped pixel in an otherwise flat plane
normals = normals_from_depth(depth, _K())
assert not np.isnan(normals).any()
# The hole pixel itself, and every pixel that depends on it as an
# immediate neighbour in the central-difference stencil, must be the
# zero vector.
for (i, j) in [(50, 50), (49, 50), (51, 50), (50, 49), (50, 51)]:
assert np.array_equal(normals[i, j], [0.0, 0.0, 0.0])
# A pixel two steps away is unaffected.
assert np.allclose(normals[47, 50], [0.0, 0.0, -1.0], atol=1e-6)
def test_explicit_valid_mask_is_honoured(self):
depth = np.full((20, 20), 2.0, dtype=np.float32)
valid = np.ones((20, 20), dtype=bool)
valid[10, 10] = False # depth > 0 here, but explicitly marked invalid
normals = normals_from_depth(depth, _K(), valid=valid)
assert np.array_equal(normals[10, 10], [0.0, 0.0, 0.0])
def test_border_pixels_are_zero(self):
depth = np.full((20, 20), 2.0, dtype=np.float32)
normals = normals_from_depth(depth, _K())
assert np.array_equal(normals[0, :], np.zeros((20, 3)))
assert np.array_equal(normals[-1, :], np.zeros((20, 3)))
assert np.array_equal(normals[:, 0], np.zeros((20, 3)))
assert np.array_equal(normals[:, -1], np.zeros((20, 3)))
def test_too_small_input_returns_all_zero(self):
depth = np.full((2, 2), 1.0, dtype=np.float32)
normals = normals_from_depth(depth, _K())
assert normals.shape == (2, 2, 3)
assert np.array_equal(normals, np.zeros((2, 2, 3)))
class TestValidation:
def test_bad_depth_ndim_raises(self):
with pytest.raises(GeometryError):
normals_from_depth(np.zeros((4, 4, 4)), _K())
def test_bad_k_shape_raises(self):
with pytest.raises(GeometryError):
normals_from_depth(np.zeros((4, 4)), np.eye(4))
def test_valid_shape_mismatch_raises(self):
with pytest.raises(GeometryError):
normals_from_depth(np.zeros((4, 4)), _K(), valid=np.ones((5, 5), dtype=bool))
class TestEncodeDecodeRoundTrip:
def test_encode_known_values(self):
normals = np.array([[[0.0, 0.0, -1.0], [1.0, -1.0, 0.0]]], dtype=np.float32)
rgb = encode_normals_rgb(normals)
assert rgb.dtype == np.uint8
assert tuple(rgb[0, 0]) == (128, 128, 0)
assert tuple(rgb[0, 1]) == (255, 0, 128)
def test_decode_inverts_encode_within_quantization(self):
rng = np.random.default_rng(1)
raw = rng.normal(size=(8, 8, 3))
normals = raw / np.linalg.norm(raw, axis=-1, keepdims=True)
rgb = encode_normals_rgb(normals)
decoded = decode_normals_rgb(rgb)
# 8-bit quantization: within one LSB (2/255) per channel.
assert np.abs(decoded - normals).max() < 2.0 / 255.0 + 1e-6
def test_encode_bad_shape_raises(self):
with pytest.raises(GeometryError):
encode_normals_rgb(np.zeros((4, 4, 2)))
def test_decode_bad_shape_raises(self):
with pytest.raises(GeometryError):
decode_normals_rgb(np.zeros((4, 4)))

Xet Storage Details

Size:
5.67 kB
·
Xet hash:
1fc4699bee6e283c5f796be45b3a40d02d8195497f442599d249c22ba8a46d65

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