feat(perception): vectorised IR→RGB depth alignment

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>
This commit is contained in:
Adriano Dal PastroandClaude Opus 4.7 committed 2026-05-11 14:23:11 +02:00
1 parent f898d9b0da
commit 5354c51d60
2 files changed
+186

No files matched your search

+88
View File
@@ -0,0 +1,88 @@
"""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