#!/usr/bin/env python3 """Render deterministic, noisy head-centred depth meshes into the FPRS v1 contract (stdlib only).""" from __future__ import annotations import argparse import math import random import struct from pathlib import Path BOX_OUTLINE = [(-2, -1.5), (2, -1.5), (2, 1.5), (-2, 1.5)] L_OUTLINE = [(-2, -2), (2, -2), (2, 0), (0, 0), (0, 2), (-2, 2)] TABLE = ((-.6, 0, -.25), (1.2, .75, .7)) COUCH = ((.6, 0, .95), (1.6, .65, .65)) # Clutter for the refinement tests: a thin pole floating in the air (a stereo streak) and a column where the user # stood (a "ghost" of the user's own body or a passer-by in the depth stream). TUBE = ((1.3, 1.35, -.5), (.12, .12, 1.4)) GHOST = ((.35, .45, 0), (.3, .9, .3)) ROOMVIEW_WIDTH, ROOMVIEW_HEIGHT = 132, 128 ROOMVIEW_NEAR, ROOMVIEW_FAR = .3, 4.0 def fan(width, height, roomview): """RoomView camera axes are +Z forward / -Y image up; poses convert them to STAGE.""" sx, sy = (1.96, 1.94) if roomview else (math.tan(math.radians(55)), math.tan(math.radians(38))) for row in range(height): for col in range(width): x = (2 * col / (width - 1) - 1) * sx y = (2 * row / (height - 1) - 1) * sy if not roomview: y = -y length = math.sqrt(x * x + y * y + 1) yield x / length, y / length, (1 if roomview else -1) / length def smooth_depths(values, width, height, radius=3): """Separable box smoothing models soft stereo boundaries, without a NumPy/scipy dependency.""" result = list(values) for vertical in (False, True): source, result = result, [0.0] * len(values) lines, length, step = (width, height, width) if vertical else (height, width, 1) for line in range(lines): start = line if vertical else line * width prefix = [0.0] for i in range(length): prefix.append(prefix[-1] + source[start + i * step]) for i in range(length): lo, hi = max(0, i - radius), min(length, i + radius + 1) result[start + i * step] = (prefix[hi] - prefix[lo]) / (hi - lo) return result def inside(x, z, polygon): odd = False for (ax, az), (bx, bz) in zip(polygon, polygon[1:] + polygon[:1], strict=False): if (az > z) != (bz > z) and x < (bx - ax) * (z - az) / (bz - az) + ax: odd = not odd return odd def rotate(v, q): x, y, z = v qx, qy, qz, qw = q tx, ty, tz = 2 * (qy * z - qz * y), 2 * (qz * x - qx * z), 2 * (qx * y - qy * x) return (x + qw * tx + qy * tz - qz * ty, y + qw * ty + qz * tx - qx * tz, z + qw * tz + qx * ty - qy * tx) def cast(eye, direction, polygon, ceiling, furniture, missing_wall): ex, ey, ez = eye dx, dy, dz = direction nearest = 5.0 hit = False for height in (0, ceiling): if abs(dy) > 1e-8: t = (height - ey) / dy if .3 < t < nearest and inside(ex + t * dx, ez + t * dz, polygon): nearest, hit = t, True for index, ((ax, az), (bx, bz)) in enumerate(zip(polygon, polygon[1:] + polygon[:1], strict=False)): if index == missing_wall: continue sx, sz = bx - ax, bz - az det = dx * sz - dz * sx if abs(det) < 1e-8: continue t = ((ax - ex) * sz - (az - ez) * sx) / det u = ((ax - ex) * dz - (az - ez) * dx) / det if .3 < t < nearest and 0 <= u <= 1 and 0 <= ey + t * dy <= ceiling: nearest, hit = t, True for (cx, bottom, cz), (width, height, depth) in furniture: near, far = -1e20, 1e20 for pos, ray, lo, hi in zip(eye, direction, (cx - width / 2, bottom, cz - depth / 2), (cx + width / 2, bottom + height, cz + depth / 2), strict=False): if abs(ray) < 1e-8: if not lo <= pos <= hi: far = -1e20 break else: a, b = (lo - pos) / ray, (hi - pos) / ray near, far = max(near, min(a, b)), min(far, max(a, b)) if .3 < near < nearest and near <= far: nearest, hit = near, True return nearest if hit else None def render(path: Path, *, shape="box", frames=30, width=None, height=None, noise=.004, holes=.025, smear=.012, floor_bias=.008, no_ceiling=False, seed=7, yaw=0.0, offset=(0.0, 0.0, 0.0), missing_wall=-1, objects=True, profile="roomview", invalid_start=None, smoothing=3, extra=()): """Each frame has its own moving pose; no ground-truth room data is put in the dump.""" rng = random.Random(seed) roomview = profile == "roomview" width = width or (ROOMVIEW_WIDTH if roomview else 56) height = height or (ROOMVIEW_HEIGHT if roomview else 40) invalid_start = (2 if roomview else 0) if invalid_start is None else invalid_start polygon = BOX_OUTLINE if shape == "box" else L_OUTLINE ceiling = 2.6 furniture = ([TABLE, COUCH] if objects and shape == "box" else []) + list(extra) rotation = (0, math.sin(yaw / 2), 0, math.cos(yaw / 2)) rays = list(fan(width, height, roomview)) with path.open("wb") as out: out.write(struct.pack("<4sI", b"FPRS", 1)) for frame in range(frames): heading = 2 * math.pi * frame / max(frames, 1) pitch = -.85 if no_ceiling else (-.60, 0, .70)[frame % 3] if roomview and no_ceiling: pitch = -1.2 # The L's two arms need viewpoints on both sides of the re-entrant corner. centre = (-.65, -.65) if shape == "l" else (0, 0) eye = (centre[0] + .35 * math.cos(heading), 1.6 + .04 * math.sin(heading * 2), centre[1] + .35 * math.sin(heading)) if roomview: eye = eye[0], 1.3 + .4 * math.sin(heading), eye[2] sy, cy = math.sin(heading / 2), math.cos(heading / 2) sp, cp = math.sin(pitch / 2), math.cos(pitch / 2) q = (cy * sp, sy * cp, -sy * sp, cy * cp) if roomview: q = q[3], q[2], -q[1], -q[0] # head rotation composed with camera's 180 degree X rotation depths = [cast(eye, rotate(ray, q), polygon, ceiling, furniture, missing_wall) for ray in rays] if roomview: # Smooth both valid depth and inpainted far pixels. Low-frequency noise survives the filter, # while isolated high-frequency speckle is small, as in a stereo patch fit. coarse_width = (width + 11) // 12 coarse = [rng.gauss(0, .012) for _ in range(coarse_width * ((height + 11) // 12))] depths = [(depth if depth is not None else 8 + rng.random() * 2) + coarse[(i // width // 12) * coarse_width + (i % width // 12)] for i, depth in enumerate(depths)] depths = smooth_depths(depths, width, height, smoothing) vertices, valid = [], [] for index, (ray, depth) in enumerate(zip(rays, depths, strict=False)): keep = depth is not None and rng.random() >= holes valid.append(keep or roomview) if roomview and not keep: depth = 8 + rng.random() * 2 if depth is None: depth = 0 else: # Stereo edges blend two depths, producing stretched grazing triangles. other = depths[index + 1] if (index + 1) % width and index + 1 < len(depths) else depth if other is not None and abs(other - depth) > .3 and rng.random() < smear: depth = (depth + other) / 2 world_ray = rotate(ray, q) if eye[1] + depth * world_ray[1] < .15: depth += floor_bias * rng.random() depth += rng.gauss(0, noise) vertices.extend(c * depth for c in ray) indices = [] for row in range(height - 1): for col in range(width - 1): a = row * width + col for triangle in ((a, a + width, a + 1), (a + 1, a + width, a + width + 1)): if all(valid[i] for i in triangle): indices.extend(triangle) # Compose the global room transform with each independent mesh -> room pose. pose_eye = tuple(a + b for a, b in zip(rotate(eye, rotation), offset, strict=False)) ax, ay, az, aw = rotation bx, by, bz, bw = q pose_q = (aw * bx + ax * bw + ay * bz - az * by, aw * by - ax * bz + ay * bw + az * bx, aw * bz + ax * by - ay * bx + az * bw, aw * bw - ax * bx - ay * by - az * bz) if frame < invalid_start: pose_eye = (float("nan"),) * 3 out.write(struct.pack("