Spaces:
Running
Running
Download projtex/camera.py from townbox/projection-texturing: direct link, hf CLI and curl.
- Browser
- Download file 5.79 kB
-
https://huggingface.co/spaces/townbox/projection-texturing/resolve/main/projtex/camera.py
- Command line
-
hf download hf://spaces/townbox/projection-texturing/projtex/camera.py
-
curl -L -o camera.py https://huggingface.co/spaces/townbox/projection-texturing/resolve/main/projtex/camera.py
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 | |
| 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 | |
| def eye_np(self) -> np.ndarray: | |
| return np.asarray(self.eye, dtype=np.float64) | |
| 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 | |
| def aspect(self) -> float: | |
| return self.width / self.height | |
| def tan_half_fov(self) -> float: | |
| return float(np.tan(np.radians(self.fov) / 2)) | |
| 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 | |
| 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 | |
| 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)) | |