""" verify_rotation.py — scratch check for P1-4 default tile rotation. Computes the wall-aligned rotation from a bundle (same algorithm as estimate_default_rotation in app.py), then renders a brick grid with rotation 0 (left) vs the computed rotation (right) using the exact frontend rotation math, to confirm the sign and magnitude visually. Usage: python verify_rotation.py /tmp/hf_new_bundle.json """ import base64 import io import json import sys import numpy as np from PIL import Image def estimate_default_rotation(mask, H, vp2=None): h, w = mask.shape[:2] def _to_plane(img_pts): ones = np.ones((len(img_pts), 1), dtype=np.float64) p = np.hstack([img_pts.astype(np.float64), ones]) @ H.T zs = p[:, 2] valid = np.abs(zs) > 1e-9 if valid.sum() < 2: return None return p[valid, 0] / zs[valid], p[valid, 1] / zs[valid] pts = [] for x in range(int(w * 0.2), int(w * 0.8), 4): col = np.where(mask[:, x] > 0)[0] if len(col) > 10: pts.append((float(x), float(col[0]))) if len(pts) >= 12: plane = _to_plane(np.array(pts)) if plane is not None: px, py = plane full_span = px.max() - px.min() keep = np.ones(len(px), dtype=bool) angle = None for i in range(3): if keep.sum() < 12: break sub_x, sub_y = px[keep], py[keep] span = sub_x.max() - sub_x.min() if span < max(1e-6, full_span * 0.3): break slope, intercept = np.polyfit(sub_x, sub_y, 1) resid = np.abs(py - (slope * px + intercept)) rms = float(np.sqrt(np.mean(resid[keep] ** 2))) print(f" pass {i}: n={keep.sum()} span={span:.0f} rms={rms:.1f} " f"({rms / span:.3f} of span) angle={np.degrees(np.arctan(slope)):.1f}") if rms < span * 0.04 and keep.sum() >= len(px) * 0.6: angle = float(np.degrees(np.arctan(slope))) break keep &= resid <= np.percentile(resid[keep], 70) if angle is not None and abs(angle) < 25.0: return angle return 0.0 def decode_pixels(bundle): w, h = bundle["width"], bundle["height"] raw = base64.b64decode(bundle["pixels"]) if len(raw) == w * h * 4: return np.frombuffer(raw, np.uint8).reshape(h, w, 4)[:, :, :3].copy() return np.array(Image.open(io.BytesIO(raw)).convert("RGB")) def render(bundle, rotation_deg): w, h = bundle["width"], bundle["height"] img = decode_pixels(bundle) seg = bundle["segments"][0] idx = np.frombuffer(base64.b64decode(seg["mask"]), dtype=np.uint32) H = np.array(seg["homography"], dtype=np.float64).reshape(3, 3) p = seg["plane"] ys, xs = idx // w, idx % w pw, ph = p["width"], p["height"] cx, cy = p["x"] + pw / 2, p["y"] + ph / 2 repeat = max(32.0, pw * 0.18) pts = np.column_stack([xs, ys, np.ones(len(xs))]) @ H.T fx = pts[:, 0] / pts[:, 2] fy = pts[:, 1] / pts[:, 2] # frontend rotation math: rad = rot*pi/180; cos(-rad), sin(-rad) rad = rotation_deg * np.pi / 180.0 c, s = np.cos(-rad), np.sin(-rad) dx, dy = fx - cx, fy - cy rx = dx * c - dy * s ry = dx * s + dy * c u = rx / repeat v = ry / repeat row = np.floor(v).astype(int) uu = u + (row % 2) * 0.5 cell = ((np.floor(uu).astype(int) + row) % 2).astype(bool) fu, fv = uu - np.floor(uu), v - np.floor(v) grout = (fu < 0.06) | (fv < 0.06) color = np.where(cell[:, None], [184, 115, 51], [222, 184, 135]).astype(np.uint8) color[grout] = (60, 60, 60) out = img.copy() out[ys, xs] = (0.75 * color + 0.25 * out[ys, xs]).astype(np.uint8) return out def main(): bundle_path = sys.argv[1] with open(bundle_path) as f: bundle = json.load(f) w, h = bundle["width"], bundle["height"] seg = bundle["segments"][0] idx = np.frombuffer(base64.b64decode(seg["mask"]), dtype=np.uint32) mask = np.zeros(h * w, np.uint8) mask[idx] = 1 mask = mask.reshape(h, w) H = np.array(seg["homography"], dtype=np.float64).reshape(3, 3) rot = estimate_default_rotation(mask, H) print(f"defaultRotation = {rot:.2f} deg") a = render(bundle, 0.0) b = render(bundle, rot) Image.fromarray(np.hstack([a, b])).save("verify_out/rotation_compare.png") print("saved verify_out/rotation_compare.png (left=rotation 0, right=defaultRotation)") if __name__ == "__main__": main()