Forward-project IR depth pixels into the RGB pixel grid with a z-buffer. Output uint16 mm at RGB resolution, ready to feed SAM21. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
89 lines
3.7 KiB
Python
89 lines
3.7 KiB
Python
"""Unit tests for align_depth_to_rgb."""
|
||
from __future__ import annotations
|
||
|
||
import numpy as np
|
||
|
||
from presa_mano.perception.depth_align import align_depth_to_rgb
|
||
from presa_mano.perception.depth_to_rgb import CameraRegistration
|
||
|
||
|
||
def _identity_reg(W: int = 64, H: int = 48) -> CameraRegistration:
|
||
"""Same K for RGB and IR, no extrinsic offset."""
|
||
K = np.array([[60.0, 0.0, W / 2.0],
|
||
[0.0, 60.0, H / 2.0],
|
||
[0.0, 0.0, 1.0]], dtype=np.float64)
|
||
return CameraRegistration(
|
||
K_rgb=K.copy(), K_ir=K.copy(),
|
||
R_ir_to_rgb=np.eye(3, dtype=np.float64),
|
||
t_ir_to_rgb=np.zeros(3, dtype=np.float64),
|
||
rgb_width=W, rgb_height=H,
|
||
ir_width=W, ir_height=H,
|
||
)
|
||
|
||
|
||
def test_identity_alignment_preserves_depth():
|
||
"""K_ir == K_rgb and no extrinsic → every IR pixel maps to itself."""
|
||
reg = _identity_reg()
|
||
depth_ir = np.full((reg.ir_height, reg.ir_width), 1000, dtype=np.uint16)
|
||
depth_rgb = align_depth_to_rgb(depth_ir, reg)
|
||
assert depth_rgb.shape == (reg.rgb_height, reg.rgb_width)
|
||
assert depth_rgb.dtype == np.uint16
|
||
nonzero = depth_rgb[depth_rgb > 0]
|
||
# Coverage close to full (rounding can drop a few border pixels).
|
||
assert nonzero.size >= 0.95 * depth_rgb.size, f"coverage too low: {nonzero.size}/{depth_rgb.size}"
|
||
# All filled pixels equal the input depth (±1 mm rounding).
|
||
assert np.all(np.abs(nonzero.astype(int) - 1000) <= 1)
|
||
|
||
|
||
def test_zero_depth_pixels_stay_zero():
|
||
"""IR pixels with depth=0 must not produce any RGB output."""
|
||
reg = _identity_reg()
|
||
depth_ir = np.zeros((reg.ir_height, reg.ir_width), dtype=np.uint16)
|
||
depth_rgb = align_depth_to_rgb(depth_ir, reg)
|
||
assert depth_rgb.shape == (reg.rgb_height, reg.rgb_width)
|
||
assert int(depth_rgb.sum()) == 0
|
||
|
||
|
||
def test_sparse_pixels_preserved():
|
||
"""Two isolated IR depths produce exactly two filled RGB pixels (identity reg)."""
|
||
reg = _identity_reg(W=64, H=48)
|
||
depth_ir = np.zeros((reg.ir_height, reg.ir_width), dtype=np.uint16)
|
||
depth_ir[0, 0] = 1000
|
||
depth_ir[reg.ir_height - 1, reg.ir_width - 1] = 1000
|
||
depth_rgb = align_depth_to_rgb(depth_ir, reg)
|
||
assert (depth_rgb > 0).sum() == 2
|
||
|
||
|
||
def test_z_buffer_keeps_closer_point():
|
||
"""When two IR pixels collide on the same RGB pixel, the smaller depth wins.
|
||
|
||
Force a collision with K_ir = 2 × K_rgb (same resolution), so adjacent IR
|
||
pixels round to the same RGB pixel."""
|
||
W = H = 16
|
||
K_rgb = np.array([[60.0, 0.0, W / 2.0],
|
||
[0.0, 60.0, H / 2.0],
|
||
[0.0, 0.0, 1.0]], dtype=np.float64)
|
||
K_ir = K_rgb.copy()
|
||
K_ir[0, 0] *= 2.0 # double focal length → two IR pixels collapse to one RGB pixel
|
||
K_ir[1, 1] *= 2.0
|
||
reg = CameraRegistration(
|
||
K_rgb=K_rgb, K_ir=K_ir,
|
||
R_ir_to_rgb=np.eye(3, dtype=np.float64),
|
||
t_ir_to_rgb=np.zeros(3, dtype=np.float64),
|
||
rgb_width=W, rgb_height=H, ir_width=W, ir_height=H,
|
||
)
|
||
depth_ir = np.zeros((H, W), dtype=np.uint16)
|
||
# Two IR pixels (5, 5) and (6, 5) both project to the same RGB pixel near (4, 5)
|
||
# (when projected through K_ir with depth 1m and re-projected through K_rgb).
|
||
depth_ir[5, 5] = 1500
|
||
depth_ir[5, 6] = 800
|
||
depth_rgb = align_depth_to_rgb(depth_ir, reg)
|
||
nonzero = depth_rgb[depth_rgb > 0]
|
||
# At least one RGB pixel must receive the closer (800) value; nothing further
|
||
# may be ≥ 1500 if a collision actually occurred. If they didn't collide, we
|
||
# see exactly two values [800, 1500]; if they did, we see only [800].
|
||
assert nonzero.size in (1, 2)
|
||
assert int(nonzero.min()) == 800
|
||
if nonzero.size == 1:
|
||
assert int(nonzero[0]) == 800 # collision happened, closer point won
|