"""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))