#!/usr/bin/env python3
"""Synthetic "camera" — a deterministic textured 3D-ish scene with a moving
camera so the pipeline can be tested without a webcam, end to end, offline.
Modes:
pixi run python scene.py still out.png [--t 2.5] render ONE frame at time t
pixi run python scene.py frames out_dir [--t0 0] [--t1 6] [--fps 10] batch stills
pixi run scene show the live feed (no pipeline)
This is the scene the whole company develops against. Improving fidelity is
allowed — but the camera PATH is contract: don't change it without a
meeting and an updated judge.py.
"""
import argparse
import sys
from pathlib import Path
import numpy as np
import cv2
W, H = 640, 400
FPS = 30.0
FOCAL = 260.0
def camera_path(t):
"""Deterministic camera pose over time (a slow forward dolly + pan)."""
x = 2.4 * t * np.sin(0.35 * t)
z = 1.0 + 1.5 * t
yaw = 0.12 * np.sin(0.5 * t) # radians
return np.array([x, 0.0, z]), yaw
def landmarks():
"""Static 3D points on three walls + floor, deterministic."""
rng = np.random.default_rng(7)
pts = []
# left wall x=-4
pts.append(np.column_stack([np.full(60, -4.0), rng.uniform(-2, 2.2, 60), rng.uniform(2, 9, 60)]))
# right wall x=+4
pts.append(np.column_stack([np.full(60, 4.0), rng.uniform(-2, 2.2, 60), rng.uniform(2, 9, 60)]))
# floor y=-2 (weak relevance: keep fewer dots)
pts.append(np.column_stack([rng.uniform(-4, 4, 25), np.full(25, -2.0), rng.uniform(2, 9, 25)]))
return np.concatenate(pts)
LANDMARKS = landmarks()
def render(t):
(pos, yaw) = camera_path(t)
c, s = np.cos(yaw), np.sin(yaw)
rel = LANDMARKS - pos
xs, ys, zs = rel[:, 0], rel[:, 1], rel[:, 2]
xc = c * xs + s * zs # camera yaws about the world-Y axis
zc = -s * xs + c * zs # zc = depth along the camera's view axis
valid = zc > 0.3
px = FOCAL * xc[valid] / zc[valid] + W / 2
py = -FOCAL * ys[valid] / zc[valid] + H / 2
frame = np.full((H, W, 3), 40, np.uint8)
depth = zc[valid]
for (x, y, d) in zip(px, py, depth):
color = tuple(int(v) for v in (255 - 18 * d, 210 - 12 * d, 160 - 6 * d))
xi, yi = int(round(float(x))), int(round(float(y)))
if 0 <= yi < H and 0 <= xi < W:
b = 2 + int(1.6 / d)
cv2.circle(frame, (xi, yi), b, color, -1)
return frame
def main():
ap = argparse.ArgumentParser()
ap.add_argument("mode", choices=["still", "frames", "stream"])
ap.add_argument("out", nargs="?", default="out.png")
ap.add_argument("--t", type=float, default=2.5)
ap.add_argument("--t0", type=float, default=0.0)
ap.add_argument("--t1", type=float, default=6.0)
ap.add_argument("--fps", type=float, default=10.0)
a = ap.parse_args()
if a.mode == "still":
cv2.imwrite(a.out, render(a.t))
elif a.mode == "frames":
out = Path(a.out)
out.mkdir(parents=True, exist_ok=True)
n = int((a.t1 - a.t0) * a.fps)
for i in range(n):
cv2.imwrite(str(out / f"f{i:04d}.png"), render(a.t0 + i / a.fps))
print(f"wrote {n} frames to {out}")
else:
t = 0.0
while True:
cv2.imshow("scene (raw feed)", render(t))
if cv2.waitKey(int(1000 / FPS)) == 27:
break
t += 1 / FPS
cv2.destroyAllWindows()
if __name__ == "__main__":
sys.exit(main())