File size: 7,191 Bytes
6a75a66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0d1fbc6
 
6a75a66
 
0d1fbc6
 
6a75a66
 
0d1fbc6
6a75a66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0d1fbc6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a75a66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0d1fbc6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a75a66
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
"""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())