room-visualizer / verify_geometry.py
GitHub Actions
Deploy from GitHub commit 680e25945def44db0c4ed5bff0330705d50b8a2a
1b6a616
Raw
History Blame Contribute Delete
12.2 kB
"""
verify_geometry.py — scratch harness for the T1 perspective fix.
Renders a procedural brick grid through the OLD and NEW homography
constructions on a real bundle, side by side, so the foreshortening
difference is directly visible. Geometry functions are copied from app.py
(pure cv2/numpy) so this runs without torch/transformers installed.
Usage:
python verify_geometry.py data/current_bundle.vizbundle.json
"""
import base64
import io
import json
import sys
import cv2
import numpy as np
from PIL import Image
# --- copied from app.py (pure geometry helpers) -----------------------------
def fit_floor_edges(mask):
h, w = mask.shape[:2]
row_ys, lefts, rights = [], [], []
step = max(1, h // 260)
for y in range(0, h, step):
row_xs = np.where(mask[y] > 0)[0]
if len(row_xs) < max(8, w * 0.01):
continue
row_ys.append(float(y))
lefts.append(float(np.percentile(row_xs, 3)))
rights.append(float(np.percentile(row_xs, 97)))
if len(row_ys) < 8:
return None
row_ys_np = np.asarray(row_ys, dtype=np.float32)
return np.polyfit(row_ys_np, np.asarray(lefts, dtype=np.float32), 1), np.polyfit(
row_ys_np, np.asarray(rights, dtype=np.float32), 1
)
def convex_hull_quad(mask):
ys, xs = np.where(mask > 0)
if len(xs) < 50:
return None
pts = np.column_stack([xs, ys]).astype(np.float32)
hull = cv2.convexHull(pts)
if hull is None or len(hull) < 4:
return None
rect = cv2.minAreaRect(hull.squeeze())
box = cv2.boxPoints(rect)
h, w = mask.shape[:2]
box[:, 0] = np.clip(box[:, 0], 0, w - 1)
box[:, 1] = np.clip(box[:, 1], 0, h - 1)
return box
def detect_dual_vanishing_points(img_np, floor_mask):
gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)
gray = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(gray, 60, 160)
edges[floor_mask == 0] = 0
lines = cv2.HoughLinesP(
edges, rho=1, theta=np.pi / 180, threshold=60,
minLineLength=max(40, min(img_np.shape[:2]) // 16), maxLineGap=24,
)
if lines is None:
return None, None
h, w = img_np.shape[:2]
pos_lines, neg_lines = [], []
for line in lines[:, 0, :]:
x1, y1, x2, y2 = [float(v) for v in line]
dx, dy = x2 - x1, y2 - y1
length = float(np.hypot(dx, dy))
if length < 40 or abs(dx) < 1:
continue
slope = dy / dx
if abs(slope) < 0.18:
continue
entry = (x1, y1, x2, y2, slope, length)
(pos_lines if slope > 0 else neg_lines).append(entry)
def _find_vp(group):
intersections = []
for i, (x1, y1, _, _, s1, l1) in enumerate(group):
a1 = y1 - s1 * x1
for x3, y3, _, _, s2, l2 in group[i + 1:]:
if abs(s1 - s2) < 0.08:
continue
denom = s1 - s2
if abs(denom) < 1e-9:
continue
a2 = y3 - s2 * x3
x = (a2 - a1) / denom
y = s1 * x + a1
if -w * 0.6 <= x <= w * 1.6 and -h * 1.2 <= y <= h * 1.0:
intersections.append((x, y, min(l1, l2)))
if len(intersections) < 3:
return None
pts = np.array([[p[0], p[1]] for p in intersections], np.float32)
weights = np.array([p[2] for p in intersections], np.float32)
center = np.average(pts, axis=0, weights=weights)
dist = np.linalg.norm(pts - center, axis=1)
keep = dist <= np.percentile(dist, 70)
if keep.sum() >= 3:
center = np.average(pts[keep], axis=0, weights=weights[keep])
return {"x": float(center[0]), "y": float(center[1])}
vp_right = _find_vp(pos_lines)
vp_left = _find_vp(neg_lines)
candidates = [(vp, abs(vp["y"])) for vp in [vp_right, vp_left] if vp is not None]
if not candidates:
return None, None
candidates.sort(key=lambda t: t[1])
primary = candidates[0][0]
secondary = candidates[1][0] if len(candidates) > 1 else None
return primary, secondary
def _shared_quad_setup(mask):
"""Common src-quad construction shared by old/new paths (pre-VP)."""
ys, xs = np.where(mask > 0)
xs_f, ys_f = xs.astype(np.float32), ys.astype(np.float32)
x1, x2 = float(np.percentile(xs_f, 1)), float(np.percentile(xs_f, 99))
y1, y2 = float(np.percentile(ys_f, 1)), float(np.percentile(ys_f, 99))
width, height = x2 - x1, y2 - y1
top_y = float(np.percentile(ys_f, 8))
bottom_y = float(np.percentile(ys_f, 97))
left_fit, right_fit = fit_floor_edges(mask)
top_left = float(np.polyval(left_fit, top_y))
top_right = float(np.polyval(right_fit, top_y))
bottom_left = float(np.polyval(left_fit, bottom_y))
bottom_right = float(np.polyval(right_fit, bottom_y))
lower_xs = xs_f[ys_f >= np.percentile(ys_f, 80)]
bottom_left = min(bottom_left, float(np.percentile(lower_xs, 4)))
bottom_right = max(bottom_right, float(np.percentile(lower_xs, 96)))
min_top_width = max(24.0, width * 0.18)
top_center = (top_left + top_right) * 0.5
if top_right - top_left < min_top_width:
top_left = top_center - min_top_width * 0.5
top_right = top_center + min_top_width * 0.5
min_bottom_width = max(min_top_width * 1.25, width * 0.45)
bottom_center = (bottom_left + bottom_right) * 0.5
if bottom_right - bottom_left < min_bottom_width:
bottom_left = bottom_center - min_bottom_width * 0.5
bottom_right = bottom_center + min_bottom_width * 0.5
h, w = mask.shape[:2]
src = np.float32([
[np.clip(bottom_left, 0, w - 1), np.clip(bottom_y, 0, h - 1)],
[np.clip(bottom_right, 0, w - 1), np.clip(bottom_y, 0, h - 1)],
[np.clip(top_right, 0, w - 1), np.clip(top_y, 0, h - 1)],
[np.clip(top_left, 0, w - 1), np.clip(top_y, 0, h - 1)],
])
return src, (x1, x2, y1, y2, width, height, top_y, bottom_y, top_center)
def estimate_old(mask, img_np):
src, (x1, x2, y1, y2, width, height, top_y, bottom_y, top_center) = _shared_quad_setup(mask)
h, w = mask.shape[:2]
vanishing_point, _ = detect_dual_vanishing_points(img_np, mask)
if vanishing_point is not None and vanishing_point["y"] < bottom_y:
vp_x = float(np.clip(vanishing_point["x"], -w * 0.25, w * 1.25))
top_width = max(src[2][0] - src[3][0], width * 0.16)
horizon_gap = max(bottom_y - top_y, 1.0)
convergence = np.clip((top_y - vanishing_point["y"]) / horizon_gap, 0.12, 0.75)
top_center = top_center * (1 - convergence * 0.35) + vp_x * (convergence * 0.35)
src[3][0] = np.clip(top_center - top_width * 0.5, 0, w - 1)
src[2][0] = np.clip(top_center + top_width * 0.5, 0, w - 1)
hull_box = convex_hull_quad(mask)
if hull_box is not None:
src[0][0] = min(src[0][0], float(np.min(hull_box[:, 0])))
src[1][0] = max(src[1][0], float(np.max(hull_box[:, 0])))
src[0][1] = src[1][1] = max(src[0][1], float(np.max(hull_box[:, 1])))
src[2][1] = src[3][1] = min(src[2][1], float(np.min(hull_box[:, 1])))
src = np.clip(src, [0, 0], [w - 1, h - 1]).astype(np.float32)
dst = np.float32([[x1, y2], [x2, y2], [x2, y1], [x1, y1]])
H = cv2.getPerspectiveTransform(src, dst)
return H, {"x": x1, "y": y1, "width": width, "height": height}, src
def estimate_new(mask, img_np):
src, (x1, x2, y1, y2, width, height, top_y, bottom_y, top_center) = _shared_quad_setup(mask)
h, w = mask.shape[:2]
vanishing_point, _ = detect_dual_vanishing_points(img_np, mask)
hull_box = convex_hull_quad(mask)
if hull_box is not None:
src[0][0] = min(src[0][0], float(np.min(hull_box[:, 0])))
src[1][0] = max(src[1][0], float(np.max(hull_box[:, 0])))
src[0][1] = src[1][1] = max(src[0][1], float(np.max(hull_box[:, 1])))
src[2][1] = src[3][1] = min(src[2][1], float(np.min(hull_box[:, 1])))
src = np.clip(src, [0, 0], [w - 1, h - 1]).astype(np.float32)
bottom_y_f, top_y_f = float(src[0][1]), float(src[3][1])
bl_x, br_x = float(src[0][0]), float(src[1][0])
span_y = max(bottom_y_f - top_y_f, 1.0)
bottom_w = max(br_x - bl_x, 1.0)
if vanishing_point is not None and vanishing_point["y"] < top_y_f - 4:
vp_x = float(np.clip(vanishing_point["x"], -w * 0.5, w * 1.5))
vp_y = float(vanishing_point["y"])
else:
vp_x = (bl_x + br_x) * 0.5
vp_y = top_y_f - 0.35 * span_y
vp_y = min(vp_y, top_y_f - 0.08 * span_y)
t = (top_y_f - bottom_y_f) / (vp_y - bottom_y_f)
src[3][0] = bl_x + (vp_x - bl_x) * t
src[2][0] = br_x + (vp_x - br_x) * t
if src[2][0] - src[3][0] < bottom_w * 0.06:
top_cx = (float(src[2][0]) + float(src[3][0])) * 0.5
src[3][0] = top_cx - bottom_w * 0.03
src[2][0] = top_cx + bottom_w * 0.03
depth_ratio = (bottom_y_f - vp_y) / max(top_y_f - vp_y, 1e-3)
dst_h = width * float(w) * (depth_ratio - 1.0) / bottom_w
dst_h = float(np.clip(dst_h, height * 0.8, height * 5.0))
dst = np.float32([[x1, y2], [x2, y2], [x2, y2 - dst_h], [x1, y2 - dst_h]])
H = cv2.getPerspectiveTransform(src, dst)
print(f" VP used: ({vp_x:.0f}, {vp_y:.0f}) detected={vanishing_point is not None}")
print(f" top width: {src[2][0]-src[3][0]:.0f}px vs bottom {bottom_w:.0f}px "
f"(ratio {(src[2][0]-src[3][0])/bottom_w:.2f})")
print(f" depth_ratio: {depth_ratio:.2f} dst_h: {dst_h:.0f} (bbox h was {height:.0f})")
return H, {"x": x1, "y": y2 - dst_h, "width": width, "height": dst_h}, src
# --- renderer ----------------------------------------------------------------
def render_grid(img_np, mask, H, plane, label, repeat_mode):
"""Inverse-map every mask pixel through H and paint a brick grid."""
out = img_np.copy()
ys, xs = np.where(mask > 0)
pts = np.column_stack([xs, ys]).astype(np.float64)
ones = np.ones((len(pts), 1))
p = np.hstack([pts, ones]) @ H.T
fx = p[:, 0] / p[:, 2]
fy = p[:, 1] / p[:, 2]
pw, ph = plane["width"], plane["height"]
if repeat_mode == "old":
repeat = max(48.0, min(pw, ph) * 0.22)
else:
repeat = max(32.0, pw * 0.18)
u = (fx - plane["x"]) / repeat
v = (fy - plane["y"]) / repeat
# brick pattern: offset every other row by half a tile, dark grout lines
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[ys, xs] = (0.75 * color + 0.25 * out[ys, xs]).astype(np.uint8)
return out
def main():
bundle_path = sys.argv[1] if len(sys.argv) > 1 else "data/current_bundle.vizbundle.json"
with open(bundle_path) as f:
bundle = json.load(f)
w, h = bundle["width"], bundle["height"]
img = Image.open(io.BytesIO(base64.b64decode(bundle["pixels"]))).convert("RGB")
img_np = np.array(img)
print(f"Image: {img_np.shape}")
seg = bundle["segments"][0]
indices = np.frombuffer(base64.b64decode(seg["mask"]), dtype=np.uint32)
mask = np.zeros(h * w, np.uint8)
mask[indices] = 1
mask = mask.reshape(h, w)
print(f"Mask pixels: {int(mask.sum())}")
print("\n--- OLD geometry ---")
H_old, plane_old, src_old = estimate_old(mask, img_np)
print(f" src quad: {src_old.flatten().round(0).tolist()}")
out_old = render_grid(img_np, mask, H_old, plane_old, "old", "old")
print("\n--- NEW geometry ---")
H_new, plane_new, src_new = estimate_new(mask, img_np)
print(f" src quad: {src_new.flatten().round(0).tolist()}")
out_new = render_grid(img_np, mask, H_new, plane_new, "new", "new")
side = np.hstack([out_old, out_new])
Image.fromarray(out_old).save("verify_out/geometry_old.png")
Image.fromarray(out_new).save("verify_out/geometry_new.png")
Image.fromarray(side).save("verify_out/geometry_compare.png")
print("\nSaved verify_out/geometry_old.png, geometry_new.png, geometry_compare.png")
if __name__ == "__main__":
main()