room-visualizer / verify_r1_rotation_sim.py
GitHub Actions
Deploy from GitHub commit 0b4a46d6249523704b10c03464426d0ab07f81cd
0d1fbc6
Raw
History Blame Contribute Delete
7.19 kB
"""R1-4 β€” wall-aligned rotation v2 certification (CI-safe: analytic scene,
no torch).
Reuses the pinhole ground-plane camera from verify_r1_scale_sim and builds a
ROTATED room: a floor rectangle at a known world angle phi with wall bands
along its far edges. The analytic metre-plane homography is constructed in
closed form (and self-checked against the projection), so the only thing
under test is the REAL estimate_wall_rotation from app.py.
Checks:
1-4. recovers phi = 0 / 18 / -30 / 70 deg within 2 deg (70 folds to -20:
the tile grid is 90-deg symmetric; perpendicular walls reinforce)
5. abstains on scattered wall blobs (no straight contact line)
6. abstains when two walls disagree (0 vs 45 deg β€” conflicting cues)
"""
import cv2
import numpy as np
from verify_r1_scale_sim import CAM_H, F, H as IMG_H, PITCH, W as IMG_W
# --- real implementation from app.py ------------------------------------------
# R1-4 v3: the span covers the shared voting helper plus both estimators
# (wall-contact and floor-lattice), ending at the next section banner.
src = open("app.py").read()
ns = {"np": np, "cv2": cv2}
start = src.index("def _fold_segments_to_rotation")
end = src.index("\n# ---", start)
exec(compile(src[start:end], "app.py", "exec"), ns)
estimate_wall_rotation = ns["estimate_wall_rotation"]
estimate_floor_lattice_rotation = ns["estimate_floor_lattice_rotation"]
def plane_homography():
"""Closed-form image -> (x_w, fwd) metre-plane homography for the scene
camera (height CAM_H, pitch PITCH, f = F, principal point at centre)."""
c, s = np.cos(PITCH), np.sin(PITCH)
M = np.array(
[
[F, (IMG_W / 2.0) * c, (IMG_W / 2.0) * s * CAM_H],
[0.0, -F * s + (IMG_H / 2.0) * c, F * c * CAM_H + (IMG_H / 2.0) * s * CAM_H],
[0.0, c, s * CAM_H],
]
)
return np.linalg.inv(M)
def world_grid(Hp):
"""Per-pixel (x_w, fwd) via the plane homography; fwd<=0 marked invalid."""
yy, xx = np.mgrid[0:IMG_H, 0:IMG_W].astype(np.float64)
p = np.stack([xx, yy, np.ones_like(xx)], axis=-1) @ Hp.T
z = p[..., 2]
valid = np.abs(z) > 1e-9
x_w = np.where(valid, p[..., 0] / np.where(valid, z, 1), 0)
fwd = np.where(valid, p[..., 1] / np.where(valid, z, 1), -1)
return x_w, fwd, valid & (fwd > 0.2)
def rotated_room(phi_deg, Hp, walls=("back", "right")):
"""Floor rect at angle phi with 0.35 m wall bands on the given edges."""
x_w, fwd, ok = world_grid(Hp)
phi = np.radians(phi_deg)
f_c = CAM_H / np.tan(PITCH) * 0.9 + 2.0
fx = fwd - f_c
xr = np.cos(phi) * x_w + np.sin(phi) * fx
fr = -np.sin(phi) * x_w + np.cos(phi) * fx
HX, HF = 4.0, 2.0
floor = ok & (np.abs(xr) <= HX) & (np.abs(fr) <= HF)
wall = np.zeros_like(floor)
if "back" in walls:
wall |= ok & (np.abs(xr) <= HX) & (fr > HF) & (fr <= HF + 0.35)
if "right" in walls:
wall |= ok & (np.abs(fr) <= HF) & (xr > HX) & (xr <= HX + 0.35)
return wall.astype(np.uint8), floor.astype(np.uint8)
def lattice_room(phi_deg, Hp, tile=0.5):
"""R1-4 v3 β€” synthetic photo of an EXISTING tiled floor: dark grout lines
every `tile` metres in a world frame rotated by phi (perspective-correct
through the scene camera), on a light tile field."""
x_w, fwd, ok = world_grid(Hp)
phi = np.radians(phi_deg)
f_c = CAM_H / np.tan(PITCH) * 0.9 + 2.0
fx = fwd - f_c
xr = np.cos(phi) * x_w + np.sin(phi) * fx
fr = -np.sin(phi) * x_w + np.cos(phi) * fx
HX, HF = 4.0, 2.0
floor = ok & (np.abs(xr) <= HX) & (np.abs(fr) <= HF)
img = np.full((IMG_H, IMG_W, 3), 200, np.uint8)
gx = np.abs(xr / tile - np.round(xr / tile)) * tile < 0.015
gf = np.abs(fr / tile - np.round(fr / tile)) * tile < 0.015
img[floor & (gx | gf)] = 90
img[~floor] = 230
return img, floor.astype(np.uint8)
def fold(a):
a = a % 90.0
return a - 90.0 if a >= 45.0 else a
def main():
ok = True
Hp = plane_homography()
# self-check: the closed-form homography inverts the scene projection
f0 = CAM_H / np.tan(PITCH) * 0.9
z_c = np.cos(PITCH) * f0 + np.sin(PITCH) * CAM_H
y_c = -np.sin(PITCH) * f0 + np.cos(PITCH) * CAM_H
u, v = 1.0 / z_c * F + IMG_W / 2.0, y_c / z_c * F + IMG_H / 2.0
p = Hp @ np.array([u, v, 1.0])
assert abs(p[0] / p[2] - 1.0) < 1e-6 and abs(p[1] / p[2] - f0) < 1e-6, \
"scene homography inconsistent with projection"
for phi in (0.0, 18.0, -30.0, 70.0):
wall, floor = rotated_room(phi, Hp)
got = estimate_wall_rotation(wall, floor, Hp)
want = fold(phi)
good = got is not None and abs(fold(got - want)) <= 2.0
print(f" [{'PASS' if good else 'FAIL'}] phi={phi:+.0f} deg -> "
f"{'None' if got is None else f'{got:+.2f}'} (want {want:+.1f})")
ok &= good
# 5 β€” scattered blobs over a wide band: no dominant direction to read
rng = np.random.default_rng(7)
_, floor = rotated_room(0.0, Hp)
blobs = np.zeros((IMG_H, IMG_W), np.uint8)
edge_rows = np.where(floor.any(axis=1))[0]
top = int(edge_rows[0])
for _ in range(40):
cx = int(rng.integers(40, IMG_W - 40))
cy = int(np.clip(top + rng.integers(-60, 60), 4, IMG_H - 4))
blobs[cy - 3 : cy + 3, max(0, cx - 5) : cx + 5] = 1
got = estimate_wall_rotation(blobs, floor, Hp)
print(f" [{'PASS' if got is None else 'FAIL'}] scattered blobs -> {got}")
ok &= got is None
# 6 β€” conflicting walls: 0-deg back band + 45-deg diagonal band
wall0, floor0 = rotated_room(0.0, Hp, walls=("back",))
x_w, fwd, valid = world_grid(Hp)
f_c = CAM_H / np.tan(PITCH) * 0.9 + 2.0
diag = valid & (np.abs((fwd - f_c) - x_w) <= 0.25) & (np.abs(x_w) <= 3.0)
conflicted = np.maximum(wall0, diag.astype(np.uint8))
got = estimate_wall_rotation(conflicted, floor0, Hp)
print(f" [{'PASS' if got is None else 'FAIL'}] conflicting 0/45 walls -> {got}")
ok &= got is None
# 7-8 β€” R1-4 v3: the floor-lattice estimator recovers the EXISTING
# floor's grout direction from the photo (the cue that outranks walls)
for phi in (18.0, -30.0):
img, floor = lattice_room(phi, Hp)
got = estimate_floor_lattice_rotation(img, floor, Hp)
want = fold(phi)
good = got is not None and abs(fold(got - want)) <= 2.0
print(f" [{'PASS' if good else 'FAIL'}] lattice phi={phi:+.0f} deg -> "
f"{'None' if got is None else f'{got:+.2f}'} (want {want:+.1f})")
ok &= good
# 9 β€” carpet / lineless floor: speckle texture, no lattice β€” must abstain
# so the pipeline falls through to the wall-contact estimate
rng = np.random.default_rng(11)
_, floor = rotated_room(0.0, Hp)
carpet = np.clip(
180 + rng.normal(0, 6, (IMG_H, IMG_W, 3)), 0, 255
).astype(np.uint8)
got = estimate_floor_lattice_rotation(carpet, floor, Hp)
print(f" [{'PASS' if got is None else 'FAIL'}] carpet (speckle) -> {got}")
ok &= got is None
print("\n" + ("ALL R1-4 SIM CHECKS PASSED" if ok else "R1-4 SIM CHECKS FAILED"))
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())