NaraMotion / tests /test_perspective_camera.py
ArifiIslam's picture
Release NaraMotion v0.26.0: Structure of Arrays (SoA), Path Arena Allocator, Uniform Grid Indexing, Dual-Buffer Rendering & Centroid Spring Pop-in
b516e00 verified
Raw History Blame Contribute Delete
4.91 kB
import os
import math
import pytest
import numpy as np
import naramotion
from naramotion import Image, Context, Format, Path, VideoWriter
from naramotion.motion import PerspectiveCamera, render_globe, Timeline, Easing
def test_perspective_project_point():
cam = PerspectiveCamera(width=1920, height=1080, fov=60.0, pitch=0.0, distance=1000.0)
# Point at origin (0, 0) should project directly to viewport center (960, 540)
sx, sy, ds, ok = cam.project_point(0.0, 0.0)
assert ok is True
assert math.isclose(sx, 960.0, abs_tol=1e-2)
assert math.isclose(sy, 540.0, abs_tol=1e-2)
assert math.isclose(ds, 1.0, abs_tol=1e-2)
def test_perspective_pitch_and_horizon_clipping():
# Camera tilted 60 degrees forward
cam = PerspectiveCamera(width=1920, height=1080, fov=60.0, pitch=60.0, distance=500.0)
# Point directly in front
sx1, sy1, ds1, ok1 = cam.project_point(0.0, 200.0)
assert ok1 is True
assert sy1 < 540.0 # Appears higher towards horizon
# Point far behind camera should be clipped by near-plane
sx2, sy2, ds2, ok2 = cam.project_point(0.0, -5000.0)
assert ok2 is False or ds2 <= 0.0
def test_perspective_transform_points_numpy():
cam = PerspectiveCamera(width=1920, height=1080, fov=60.0, pitch=45.0, distance=800.0)
# (1000, 2) grid points
grid_x, grid_y = np.meshgrid(np.linspace(-500, 500, 50), np.linspace(-500, 500, 20))
pts = np.column_stack([grid_x.ravel(), grid_y.ravel()])
proj_pts, depth_scales, valid_mask = cam.transform_points(pts)
assert proj_pts.shape == (1000, 2)
assert depth_scales.shape == (1000,)
assert valid_mask.shape == (1000,)
assert np.any(valid_mask)
def test_perspective_transform_lines():
cam = PerspectiveCamera(width=1920, height=1080, fov=60.0, pitch=30.0, distance=600.0)
lines = np.array([
[-100.0, -100.0, 100.0, -100.0],
[100.0, -100.0, 100.0, 100.0],
[100.0, 100.0, -100.0, 100.0],
[-100.0, 100.0, -100.0, -100.0],
], dtype=np.float64)
proj_lines, depth_scales, valid_mask = cam.transform_lines(lines)
assert proj_lines.shape == (4, 4)
assert len(depth_scales) == 4
assert np.all(valid_mask)
def test_perspective_transform_path():
cam = PerspectiveCamera(width=1920, height=1080, fov=60.0, pitch=45.0, distance=500.0)
path = Path()
path.add_circle(0.0, 0.0, 200.0)
proj_path, avg_ds = cam.transform_path(path)
assert not proj_path.is_empty()
assert proj_path.size >= 10
assert avg_ds > 0.0
def test_perspective_render_globe(tmp_path):
# 1. Create dummy world texture image (e.g. 512x256)
tex = Image(512, 256, Format.PRGB32)
with Context(tex) as ctx:
ctx.fill_all("#1E3A8A") # Ocean blue
ctx.fill_circle(150, 100, 60, style="#10B981") # Land green
ctx.fill_circle(350, 120, 70, style="#10B981")
# 2. Render 3D Globe onto canvas
canvas = Image(1000, 1000, Format.PRGB32)
with Context(canvas) as ctx:
ctx.fill_all("#030712") # Space black
ok = render_globe(
dst_image=canvas,
texture_image=tex,
cx=500.0, cy=500.0, radius=350.0,
rot_lon=45.0, rot_lat=15.0,
glow_color="#38BDF8FF"
)
assert ok is True
out_file = str(tmp_path / "globe_test.png")
canvas.save(out_file)
assert os.path.exists(out_file)
assert os.path.getsize(out_file) > 1000
def test_perspective_drone_flythrough_timeline(tmp_path):
video_out = str(tmp_path / "drone_perspective_flythrough.mp4")
cam = PerspectiveCamera(width=640, height=360, fov=60.0)
timeline = Timeline(duration=0.5, fps=30)
# Animate pitch from 0 deg (flat) to 70 deg (drone perspective)
timeline.animate(cam, "pitch", 0.0, 70.0, duration=0.5, easing=Easing.cubic_in_out)
timeline.animate(cam, "target_y", 0.0, 300.0, duration=0.5)
# Grid of road lines
grid_lines = []
for i in range(-5, 6):
grid_lines.append([-500.0, i * 100.0, 500.0, i * 100.0])
grid_lines.append([i * 100.0, -500.0, i * 100.0, 500.0])
lines_arr = np.array(grid_lines, dtype=np.float64)
def draw_frame(ctx: Context, t: float, frame_idx: int):
ctx.fill_all("#090D16")
proj_lines, depth_scales, mask = cam.transform_lines(lines_arr)
for line, ds, valid in zip(proj_lines, depth_scales, mask):
if not valid: continue
ctx.stroke_width = max(0.5, 2.0 * ds)
ctx.stroke_line(line[0], line[1], line[2], line[3], style="#38BDF8")
cam.render_fog(ctx, fog_color="#090D16", horizon_y_ratio=0.30)
timeline.render_video(video_out, width=640, height=360, fps=30, draw_fn=draw_frame)
assert os.path.exists(video_out)
assert os.path.getsize(video_out) > 1000