Files
aicc-capsule/testbed/room/render.py
T

474 lines
15 KiB
Python

"""Headless first-person renderer for the testbed room.
A small raycaster (Pillow-only, no GPU, no display) that produces an honest
frame: it derives every pixel from the actual world state in ``Room``. Used by
the ``vision`` and ``depth`` sensor tools.
Camera model: yaw = heading around Y, pitch = camera tilt. 90° horizontal FOV.
"""
from __future__ import annotations
import math
from PIL import Image, ImageDraw
from testbed.room.world import ROOM_HEIGHT, ROOM_SIZE, Room
# Palette
FOG = (14, 14, 20)
FLOOR_A = (56, 56, 62)
FLOOR_B = (64, 64, 70)
FLOOR_LINE = (42, 42, 48)
CEIL = (40, 40, 46)
SKY = (12, 16, 30)
WALL = (96, 96, 106)
WALL_TOP_BAND = (70, 70, 78)
BEACON_OFF = ((0.85, 1.0, 1.0), (0.42, 0.82, 0.92), (0.10, 0.45, 0.62))
BEACON_ON = ((1.0, 0.98, 0.78), (1.0, 0.75, 0.35), (0.78, 0.43, 0.0))
MAX_VIEW = 20.0
_EPS = 1e-9
class Raycaster:
"""Renders the room from the capsule's camera. Stateless per frame."""
def __init__(self, width: int = 160, height: int = 120):
self.width = width
self.height = height
self.tan_fx = 1.0 # 90° horizontal FOV
self.tan_fy = self.tan_fx * height / width
def render(
self, room: Room, max_depth: float = 10.0
) -> tuple[Image.Image, list[list[float]]]:
"""Render the current view. Returns (RGB image, aligned depth grid)."""
w, h = self.width, self.height
cap = room.capsule
px = cap.x
pz = cap.z
eye = cap.eye_height
yaw = math.radians(cap.yaw_deg)
pitch = math.radians(cap.pitch_deg)
tan_pitch = math.tan(pitch)
fx = math.sin(yaw)
fz = math.cos(yaw)
rx = math.cos(yaw)
rz = -math.sin(yaw)
pixels = bytearray(w * h * 3)
depth = [[MAX_VIEW] * w for _ in range(h)]
slabs = self._slabs(room)
for c in range(w):
u = (2.0 * (c + 0.5) / w - 1.0) * self.tan_fx
dx = fx + rx * u
dz = fz + rz * u
inv = 1.0 / math.hypot(dx, dz)
dx *= inv
dz *= inv
dist, surf = self._cast(px, pz, dx, dz, slabs)
if surf is None:
t_wall = math.inf
s_coord = 0.0
else:
t_wall = dist
s_coord = surf["s"]
row_top = self._row_of(ROOM_HEIGHT, t_wall, eye, tan_pitch)
row_bot = self._row_of(0.0, t_wall, eye, tan_pitch)
r_top = max(0, math.ceil(min(row_top, row_bot)))
r_bot = min(h, math.floor(max(row_top, row_bot)))
c * 3
for r in range(h):
v = self.tan_fy * (1.0 - 2.0 * (r + 0.5) / h)
down = v + tan_pitch
o = (r * w + c) * 3
if r_top <= r < r_bot and t_wall < MAX_VIEW:
col = self._shade_wall(surf, dist, s_coord)
if r == r_top:
col = _lerp(col, WALL_TOP_BAND, 0.55)
t = dist
elif down < -_EPS:
t = eye / -down
col = self._shade_floor(px + dx * t, pz + dz * t, t)
elif down > _EPS:
t = (ROOM_HEIGHT - eye) / down
col = _fog(CEIL, t)
else:
col = SKY
t = math.inf
if t < MAX_VIEW:
depth[r][c] = t
pixels[o] = col[0]
pixels[o + 1] = col[1]
pixels[o + 2] = col[2]
self._draw_beacon(room, pixels, depth, fx, fz, rx, rz, eye, tan_pitch)
img = Image.frombuffer("RGB", (w, h), bytes(pixels), "raw", "RGB", 0, 1)
grid = self._depth_grid(depth, max_depth)
return img, grid
# ---------- helpers ----------
def _slabs(self, room: Room) -> list[dict]:
"""Room walls as thin slabs + the obstacle boxes, all axis-aligned."""
e = 0.01
slabs: list[dict] = []
slabs.append(
{
"x0": -e,
"x1": ROOM_SIZE + e,
"z0": -e,
"z1": e,
"kind": "wall",
"id": "wall_north",
"top": ROOM_HEIGHT,
"color": WALL,
"face": "z0",
}
)
slabs.append(
{
"x0": -e,
"x1": ROOM_SIZE + e,
"z0": ROOM_SIZE - e,
"z1": ROOM_SIZE + e,
"kind": "wall",
"id": "wall_south",
"top": ROOM_HEIGHT,
"color": WALL,
"face": "z1",
}
)
slabs.append(
{
"x0": -e,
"x1": e,
"z0": -e,
"z1": ROOM_SIZE + e,
"kind": "wall",
"id": "wall_west",
"top": ROOM_HEIGHT,
"color": WALL,
"face": "x0",
}
)
slabs.append(
{
"x0": ROOM_SIZE - e,
"x1": ROOM_SIZE + e,
"z0": -e,
"z1": ROOM_SIZE + e,
"kind": "wall",
"id": "wall_east",
"top": ROOM_HEIGHT,
"color": WALL,
"face": "x1",
}
)
for box in room.boxes:
slabs.append(
{
"x0": box.x_min(),
"x1": box.x_max(),
"z0": box.z_min(),
"z1": box.z_max(),
"kind": "box",
"id": box.id,
"top": box.height,
"color": box.color,
"face": "box",
"box": box,
}
)
return slabs
def _cast(self, ox: float, oz: float, dx: float, dz: float, slabs: list[dict]):
"""Nearest 2D hit of the ray against all slabs. Returns (dist, surface)."""
best = math.inf
best_surf = None
for s in slabs:
t = _ray_aabb(ox, oz, dx, dz, s["x0"], s["x1"], s["z0"], s["z1"])
if t is not None and t < best:
best = t
best_surf = s
if best_surf is None:
return best, None
hit_x = ox + dx * best
hit_z = oz + dz * best
face = best_surf["face"]
if face == "z0" or face == "z1":
s_coord = hit_x
elif face == "x0" or face == "x1":
s_coord = hit_z
else:
# Box: pick the face by comparing t to each plane.
s_coord = (
hit_x if best_surf["box"].half_w <= best_surf["box"].half_d else hit_z
)
return best, {"surf": best_surf, "s": s_coord, "hit_x": hit_x, "hit_z": hit_z}
def _row_of(self, world_h: float, t: float, eye: float, tan_pitch: float) -> float:
"""Screen row (float) where a height `world_h` at distance `t` lands."""
if math.isinf(t):
return 0.0 if world_h > eye else float(self.height)
vv = (world_h - eye) / t - tan_pitch
return (1.0 - vv / self.tan_fy) / 2.0 * self.height
def _shade_wall(
self, surf: dict | None, dist: float, s: float
) -> tuple[int, int, int]:
if surf is None:
return SKY
s_def = surf["surf"]
color = s_def["color"]
face = s_def["face"]
shade = 1.0
if face == "x0":
shade = 0.9
elif face == "x1":
shade = 1.08
elif face == "z1":
shade = 1.0
elif face == "z0":
shade = 0.95
# Concrete panel grid: subtle stripes every 1 m.
if int(abs(s) * 1.0) % 2 == 0:
shade *= 1.06
col = (
min(255, int(color[0] * shade)),
min(255, int(color[1] * shade)),
min(255, int(color[2] * shade)),
)
return _fog(col, dist)
def _shade_floor(self, wx: float, wz: float, t: float) -> tuple[int, int, int]:
cell = math.floor(wx) + math.floor(wz)
col = FLOOR_A if cell % 2 == 0 else FLOOR_B
fx = wx - math.floor(wx)
fz = wz - math.floor(wz)
if min(fx, 1.0 - fx, fz, 1.0 - fz) < 0.07:
col = FLOOR_LINE
return _fog(col, t)
def _draw_beacon(
self,
room: Room,
pixels: bytearray,
depth: list[list[float]],
fx: float,
fz: float,
rx: float,
rz: float,
eye: float,
tan_pitch: float,
) -> None:
"""Billboard the beacon with a soft glow, occluded by the depth buffer."""
w, h = self.width, self.height
b = room.beacon
rel_x = b.x - room.capsule.x
rel_z = b.z - room.capsule.z
along = rel_x * fx + rel_z * fz
if along < 0.25:
return
right = rel_x * rx + rel_z * rz
col_c = (right / along / self.tan_fx + 1.0) / 2.0 * w
vv = (b.height - eye) / along - tan_pitch
row_c = (1.0 - vv / self.tan_fy) / 2.0 * h
r_px = b.radius / along / self.tan_fx * w / 2.0
r_px = max(r_px, 1.2)
glow = r_px * 3.4
pulse = 1.0
if b.active:
pulse = 0.9 + 0.1 * math.sin(room.tick * 0.6)
core, mid, outer = BEACON_ON if b.active else BEACON_OFF
core = tuple(pulse * v for v in core)
mid = tuple(pulse * v for v in mid)
outer = tuple(pulse * v for v in outer)
c0 = max(0, int(col_c - glow))
c1 = min(w, int(col_c + glow) + 1)
r0 = max(0, int(row_c - glow))
r1 = min(h, int(row_c + glow) + 1)
inv_r2 = 1.0 / (r_px * r_px)
for r in range(r0, r1):
for c in range(c0, c1):
if depth[r][c] <= along - 0.06:
continue # occluded by a nearer surface
dc = c - col_c
dr = r - row_c
d2 = (dc * dc + dr * dr) * inv_r2
if d2 > glow * glow * inv_r2:
continue
if d2 <= 1.0:
a = 1.0 - d2 * 0.55
col = core
elif d2 <= 4.0:
f = (d2 - 1.0) / 3.0
a = 0.85 * (1.0 - f)
col = tuple(core[i] + (mid[i] - core[i]) * f for i in range(3))
else:
f = (d2 - 4.0) / (glow * glow * inv_r2 - 4.0)
a = 0.5 * (1.0 - f)
col = tuple(mid[i] + (outer[i] - mid[i]) * f for i in range(3))
o = (r * w + c) * 3
base = (pixels[o], pixels[o + 1], pixels[o + 2])
out = (
int(base[0] * (1 - a) + col[0] * 255 * a),
int(base[1] * (1 - a) + col[1] * 255 * a),
int(base[2] * (1 - a) + col[2] * 255 * a),
)
pixels[o] = min(255, out[0])
pixels[o + 1] = min(255, out[1])
pixels[o + 2] = min(255, out[2])
def _depth_grid(
self, depth: list[list[float]], max_depth: float
) -> list[list[float]]:
"""Downsample the per-pixel depth to 1/4 resolution (40x30 at 160x120)."""
w, h = self.width, self.height
gw, gh = w // 4, h // 4
grid = [[0.0] * gw for _ in range(gh)]
for r in range(gh):
for c in range(gw):
total = 0.0
n = 0
for rr in range(r * 4, r * 4 + 4):
row = depth[rr]
for cc in range(c * 4, c * 4 + 4):
t = row[cc]
if t >= MAX_VIEW or t > max_depth:
continue
total += t
n += 1
grid[r][c] = round(total / n, 2) if n else 0.0
return grid
def render_topdown(room: Room, size: int = 400) -> Image.Image:
"""Bird's-eye view of the whole room (debug helper; uses world state).
Floor grid, crates, beacon (cyan / warm yellow when active) and the
capsule as a circle with a heading arrow.
"""
scale = size / ROOM_SIZE
img = Image.new("RGB", (size, size), (52, 52, 58))
draw = ImageDraw.Draw(img)
def xy(x: float, z: float) -> tuple[float, float]:
return (x * scale, size - z * scale)
for i in range(int(ROOM_SIZE) + 1):
c = 60 if i % 2 == 0 else 54
draw.line([xy(i, 0), xy(i, ROOM_SIZE)], fill=(c, c, c + 6), width=1)
draw.line([xy(0, i), xy(ROOM_SIZE, i)], fill=(c, c, c + 6), width=1)
for box in room.boxes:
xa, za = xy(box.x_min(), box.z_min())
xb, zb = xy(box.x_max(), box.z_max())
draw.rectangle(
[min(xa, xb), min(za, zb), max(xa, xb), max(za, zb)],
fill=box.color,
outline=(20, 20, 26),
width=2,
)
b = room.beacon
bx, bz = xy(b.x, b.z)
color = (255, 200, 90) if b.active else (120, 210, 235)
glow = (255, 240, 180) if b.active else (160, 235, 250)
r = b.radius * scale * 2.2
draw.ellipse([bx - r * 2.4, bz - r * 2.4, bx + r * 2.4, bz + r * 2.4], fill=glow)
draw.ellipse(
[bx - r, bz - r, bx + r, bz + r], fill=color, outline=(20, 20, 26), width=2
)
c = room.capsule
cx, cz = xy(c.x, c.z)
rad = c.radius * scale
draw.ellipse(
[cx - rad, cz - rad, cx + rad, cz + rad],
fill=(240, 240, 250),
outline=(20, 20, 26),
width=2,
)
fx, fz = c.forward()
tip = xy(c.x + fx * 0.7, c.z + fz * 0.7)
draw.line([(cx, cz), tip], fill=(30, 30, 40), width=3)
return img
def _ray_aabb(
ox: float,
oz: float,
dx: float,
dz: float,
x0: float,
x1: float,
z0: float,
z1: float,
) -> float | None:
"""Slab test in 2D. Returns entry distance or None."""
if abs(dx) < _EPS:
if ox < x0 - _EPS or ox > x1 + _EPS:
return None
tx0, tx1 = -math.inf, math.inf
else:
tx0, tx1 = (x0 - ox) / dx, (x1 - ox) / dx
if tx0 > tx1:
tx0, tx1 = tx1, tx0
if abs(dz) < _EPS:
if oz < z0 - _EPS or oz > z1 + _EPS:
return None
tz0, tz1 = -math.inf, math.inf
else:
tz0, tz1 = (z0 - oz) / dz, (z1 - oz) / dz
if tz0 > tz1:
tz0, tz1 = tz1, tz0
tin = max(tx0, tz0)
tout = min(tx1, tz1)
if tout < 0.0 or tin > tout:
return None
if tin > _EPS:
return tin
return None if tout <= _EPS else tout
def _fog(color: tuple[int, int, int], t: float) -> tuple[int, int, int]:
k = min(1.0, t / MAX_VIEW)
k = k**1.4
return (
int(color[0] + (FOG[0] - color[0]) * k),
int(color[1] + (FOG[1] - color[1]) * k),
int(color[2] + (FOG[2] - color[2]) * k),
)
def _lerp(
a: tuple[int, int, int], b: tuple[int, int, int], k: float
) -> tuple[int, int, int]:
return (
int(a[0] + (b[0] - a[0]) * k),
int(a[1] + (b[1] - a[1]) * k),
int(a[2] + (b[2] - a[2]) * k),
)
def render_preview(
room: Room, path: str, width: int = 320, height: int = 240
) -> Image.Image:
"""Debug helper: render the current view and save it to a PNG file."""
img, _ = Raycaster(width, height).render(room)
img.save(path)
return img