Mike0021's picture
projtex: agent-first projection texturing API (REST + MCP), Qwen Space + Fibo Space backends
bdb443d verified
Raw History Blame Contribute Delete
5.79 kB
"""Pinhole cameras (OpenGL conventions: camera looks down -Z, +Y up, image row 0 = top)."""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
import numpy as np
def _normalize(v: np.ndarray) -> np.ndarray:
n = np.linalg.norm(v)
return v / n if n > 0 else v
@dataclass
class Camera:
eye: list[float]
target: list[float]
up: list[float] = field(default_factory=lambda: [0.0, 1.0, 0.0])
fov: float = 40.0 # vertical field of view, degrees
width: int = 1024
height: int = 1024
near: float | None = None # derived from the scene when None
far: float | None = None
clip: list[float] | None = None # optional [x, y, z, r]: only geometry inside this sphere exists for the view
# ----------------------------------------------------------------- algebra
@property
def eye_np(self) -> np.ndarray:
return np.asarray(self.eye, dtype=np.float64)
@property
def forward(self) -> np.ndarray:
return _normalize(np.asarray(self.target, np.float64) - self.eye_np)
def basis(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
f = self.forward
up = np.asarray(self.up, np.float64)
if abs(np.dot(_normalize(up), f)) > 0.999: # looking straight up/down
up = np.array([0.0, 0.0, -1.0]) if f[1] < 0 else np.array([0.0, 0.0, 1.0])
r = _normalize(np.cross(f, up))
u = np.cross(r, f)
return r, u, f
def view_matrix(self) -> np.ndarray:
r, u, f = self.basis()
m = np.eye(4)
m[0, :3], m[1, :3], m[2, :3] = r, u, -f
m[:3, 3] = -m[:3, :3] @ self.eye_np
return m
@property
def aspect(self) -> float:
return self.width / self.height
@property
def tan_half_fov(self) -> float:
return float(np.tan(np.radians(self.fov) / 2))
@property
def focal_px(self) -> float:
return self.height / 2 / self.tan_half_fov
def proj_matrix(self, near: float, far: float) -> np.ndarray:
t = self.tan_half_fov
m = np.zeros((4, 4))
m[0, 0] = 1 / (t * self.aspect)
m[1, 1] = 1 / t
m[2, 2] = -(far + near) / (far - near)
m[2, 3] = -2 * far * near / (far - near)
m[3, 2] = -1
return m
def clip_planes(self, scene_center: np.ndarray, scene_radius: float) -> tuple[float, float]:
dist = float(np.linalg.norm(np.asarray(scene_center) - self.eye_np))
far = self.far or (dist + scene_radius * 1.05)
near = self.near or max(dist - scene_radius * 1.05, far * 1e-4)
return near, far
def view_proj(self, near: float, far: float) -> np.ndarray:
return self.proj_matrix(near, far) @ self.view_matrix()
def project(self, points: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""World points -> (pixel xy with row 0 at the top, linear depth along the view axis)."""
r, u, f = self.basis()
d = np.asarray(points, np.float64) - self.eye_np
z = d @ f
x = (d @ r) / np.maximum(z, 1e-12)
y = (d @ u) / np.maximum(z, 1e-12)
fx = self.focal_px
px = np.stack([self.width / 2 + x * fx, self.height / 2 - y * fx], axis=-1)
return px, z
def ray_dirs(self) -> np.ndarray:
"""Unit world-space ray directions for every pixel center, shape (H, W, 3)."""
r, u, f = self.basis()
fx = self.focal_px
xs = (np.arange(self.width) + 0.5 - self.width / 2) / fx
ys = (self.height / 2 - (np.arange(self.height) + 0.5)) / fx
d = f[None, None] + xs[None, :, None] * r[None, None] + ys[:, None, None] * u[None, None]
return d / np.linalg.norm(d, axis=-1, keepdims=True)
# ------------------------------------------------------------ construction
@classmethod
def look_at(cls, eye, target, up=(0, 1, 0), **kw) -> "Camera":
return cls(eye=[float(x) for x in eye], target=[float(x) for x in target], up=[float(x) for x in up], **kw)
def resized(self, width: int, height: int | None = None) -> "Camera":
d = asdict(self)
d["width"], d["height"] = int(width), int(height or width)
return Camera(**d)
def to_dict(self) -> dict:
d = asdict(self)
d["eye"] = [round(float(x), 6) for x in d["eye"]]
d["target"] = [round(float(x), 6) for x in d["target"]]
return d
@classmethod
def from_dict(cls, d: dict) -> "Camera":
return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
def direction_from_angles(azimuth_deg: float, elevation_deg: float, up_axis: int = 1) -> np.ndarray:
"""Unit vector from the target towards the camera.
azimuth 0 = +Z (glTF "front", the side a character faces), 90 = +X, elevation >0 = above.
"""
a, e = np.radians(azimuth_deg), np.radians(elevation_deg)
v = np.array([np.sin(a) * np.cos(e), np.sin(e), np.cos(a) * np.cos(e)])
if up_axis == 2: # Z-up scenes
v = np.array([v[0], -v[2], v[1]])
return v
def fit_distance(points: np.ndarray, target: np.ndarray, direction: np.ndarray, fov_deg: float,
aspect: float = 1.0, margin: float = 0.06, up=(0, 1, 0)) -> float:
"""Smallest camera distance along ``direction`` that keeps all ``points`` inside the frame."""
cam = Camera.look_at(target + direction, target, up=up, fov=fov_deg)
r, u, f = cam.basis()
rel = np.asarray(points, np.float64) - target
t = np.tan(np.radians(fov_deg) / 2) * (1 - 2 * margin)
zf = rel @ f # depth offset relative to the target plane
need_x = np.abs(rel @ r) / (t * aspect)
need_y = np.abs(rel @ u) / t
# point at depth (d + zf) must satisfy |x| <= t*(d+zf) => d >= need - zf
return float(np.max(np.maximum(need_x, need_y) - zf))