swarm / companies/sparrow/seed/scene.py

raw file 3438 bytes
#!/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())