-
-
Save harry7557558/1e31891d1fc8050bfdf771ef78f48c1c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Shared COLMAP export helpers for the render_*.py fractal renderers. | |
| The renderers use an OpenGL-style camera: +X right, +Y up, camera looks | |
| along -Z. COLMAP stores world->camera poses in an OpenCV-style frame: | |
| +X right, +Y down, +Z forward. ``c2w_gl_to_colmap_w2c`` converts between | |
| the two; getting this flip wrong is what makes cameras appear inverted in | |
| the COLMAP GUI. | |
| Because the renderers ray-trace an exact implicit surface, the initial | |
| point cloud is sampled directly from primary-ray hits (ground truth) | |
| instead of being triangulated by COLMAP from image features. | |
| """ | |
| import numpy as np | |
| from pathlib import Path | |
| def rotmat_to_quat(R: np.ndarray) -> np.ndarray: | |
| """Convert 3x3 rotation matrix to quaternion in COLMAP order [w, x, y, z].""" | |
| m = R | |
| tr = float(m[0, 0] + m[1, 1] + m[2, 2]) | |
| if tr > 0: | |
| S = np.sqrt(tr + 1.0) * 2.0 | |
| qw = 0.25 * S | |
| qx = (m[2, 1] - m[1, 2]) / S | |
| qy = (m[0, 2] - m[2, 0]) / S | |
| qz = (m[1, 0] - m[0, 1]) / S | |
| elif (m[0, 0] > m[1, 1]) and (m[0, 0] > m[2, 2]): | |
| S = np.sqrt(1.0 + float(m[0, 0] - m[1, 1] - m[2, 2])) * 2.0 | |
| qw = (m[2, 1] - m[1, 2]) / S | |
| qx = 0.25 * S | |
| qy = (m[0, 1] + m[1, 0]) / S | |
| qz = (m[0, 2] + m[2, 0]) / S | |
| elif m[1, 1] > m[2, 2]: | |
| S = np.sqrt(1.0 + float(m[1, 1] - m[0, 0] - m[2, 2])) * 2.0 | |
| qw = (m[0, 2] - m[2, 0]) / S | |
| qx = (m[0, 1] + m[1, 0]) / S | |
| qy = 0.25 * S | |
| qz = (m[1, 2] + m[2, 1]) / S | |
| else: | |
| S = np.sqrt(1.0 + float(m[2, 2] - m[0, 0] - m[1, 1])) * 2.0 | |
| qw = (m[1, 0] - m[0, 1]) / S | |
| qx = (m[0, 2] + m[2, 0]) / S | |
| qy = (m[1, 2] + m[2, 1]) / S | |
| qz = 0.25 * S | |
| q = np.array([qw, qx, qy, qz], dtype=np.float64) | |
| return q / np.linalg.norm(q) | |
| # Flips the camera-space +Y (up -> down) and +Z (backward -> forward) axes. | |
| GL_TO_CV = np.diag([1.0, -1.0, -1.0]) | |
| def c2w_gl_to_colmap_w2c(c2w: np.ndarray): | |
| """OpenGL camera-to-world matrix -> COLMAP world-to-camera (R, t).""" | |
| R_c2w_cv = c2w[:3, :3] @ GL_TO_CV | |
| R = R_c2w_cv.T | |
| t = -R @ c2w[:3, 3] | |
| return R, t | |
| def sample_surface_points(hits: np.ndarray, img8: np.ndarray, n_points: int, | |
| rng: np.random.Generator): | |
| """Pick up to n_points surface points from a primary-ray hit map. | |
| hits: (th, tw, 4) array of [hit_flag, x, y, z] per traced pixel. | |
| img8: (H, W, 3) uint8 rendered image used to color the points. | |
| Returns (points_xyz, points_rgb, pixels_xy) where pixels_xy are the | |
| pixel-center coordinates in full-resolution image space (for | |
| reprojection checks). | |
| """ | |
| th, tw = hits.shape[:2] | |
| ys, xs = np.nonzero(hits[:, :, 0] > 0.5) | |
| if ys.size == 0: | |
| return (np.zeros((0, 3)), np.zeros((0, 3), np.uint8), np.zeros((0, 2))) | |
| if ys.size > n_points: | |
| sel = rng.choice(ys.size, size=n_points, replace=False) | |
| ys, xs = ys[sel], xs[sel] | |
| pts = hits[ys, xs, 1:4].astype(np.float64) | |
| H, W = img8.shape[:2] | |
| pix = np.stack([(xs + 0.5) * (W / tw), (ys + 0.5) * (H / th)], axis=1) | |
| ix = np.clip(pix[:, 0].astype(np.int64), 0, W - 1) | |
| iy = np.clip(pix[:, 1].astype(np.int64), 0, H - 1) | |
| rgb = img8[iy, ix] | |
| return pts, rgb, pix | |
| def reprojection_errors(points_xyz: np.ndarray, pixels_xy: np.ndarray, | |
| R: np.ndarray, t: np.ndarray, | |
| fx: float, fy: float, cx: float, cy: float) -> np.ndarray: | |
| """Project world points with a COLMAP pose; return per-point pixel error. | |
| Points behind the camera get infinite error, so any convention mistake | |
| (axis flip, wrong inverse) shows up as a huge value instead of passing | |
| silently. | |
| """ | |
| cam = points_xyz @ R.T + t | |
| z = cam[:, 2] | |
| valid = z > 1e-9 | |
| z_safe = np.where(valid, z, 1.0) | |
| u = fx * cam[:, 0] / z_safe + cx | |
| v = fy * cam[:, 1] / z_safe + cy | |
| err = np.hypot(u - pixels_xy[:, 0], v - pixels_xy[:, 1]) | |
| err[~valid] = np.inf | |
| return err | |
| def write_colmap_text(dataset_dir, cameras, images_meta, | |
| points_xyz=None, points_rgb=None) -> None: | |
| """Write sparse/0/{cameras,images,points3D}.txt under dataset_dir. | |
| cameras: list of (camera_id, model, width, height, params) | |
| images_meta: list of (image_id, q_wxyz, t, camera_id, name); name must be | |
| relative to the images/ directory (no "images/" prefix). | |
| points_xyz/points_rgb: (N, 3) float / (N, 3) uint8 initial point cloud. | |
| """ | |
| sparse0 = Path(dataset_dir) / "sparse" / "0" | |
| sparse0.mkdir(parents=True, exist_ok=True) | |
| stale = sorted(p.name for p in sparse0.glob("*.bin")) | |
| if stale: | |
| print(f"WARNING: {sparse0} already contains a binary model " | |
| f"({', '.join(stale)}). Most loaders prefer *.bin over *.txt, " | |
| f"so delete or move those files or they will shadow this export.") | |
| if points_xyz is None: | |
| points_xyz = np.zeros((0, 3)) | |
| if points_rgb is None: | |
| points_rgb = np.full((len(points_xyz), 3), 128, np.uint8) | |
| with (sparse0 / "cameras.txt").open("w", encoding="utf-8") as f: | |
| f.write("# Camera list with one line of data per camera:\n") | |
| f.write("# CAMERA_ID, MODEL, WIDTH, HEIGHT, PARAMS[]\n") | |
| f.write("# Number of cameras: {}\n".format(len(cameras))) | |
| for cam_id, model, width, height, params in cameras: | |
| pstr = " ".join("{:.10f}".format(float(p)) for p in params) | |
| f.write(f"{cam_id} {model} {width} {height} {pstr}\n") | |
| with (sparse0 / "images.txt").open("w", encoding="utf-8") as f: | |
| f.write("# Image list with two lines of data per image:\n") | |
| f.write("# IMAGE_ID, QW, QX, QY, QZ, TX, TY, TZ, CAMERA_ID, NAME\n") | |
| f.write("# POINTS2D[] as (X, Y, POINT3D_ID)\n") | |
| f.write("# Number of images: {}\n".format(len(images_meta))) | |
| for image_id, q, t, cam_id, name in images_meta: | |
| qstr = " ".join("{:.10f}".format(float(v)) for v in q) | |
| tstr = " ".join("{:.10f}".format(float(v)) for v in t) | |
| f.write(f"{image_id} {qstr} {tstr} {cam_id} {name}\n") | |
| f.write("\n") | |
| with (sparse0 / "points3D.txt").open("w", encoding="utf-8") as f: | |
| f.write("# 3D point list with one line of data per point:\n") | |
| f.write("# POINT3D_ID, X, Y, Z, R, G, B, ERROR, TRACK[] as (IMAGE_ID, POINT2D_IDX)\n") | |
| f.write("# Number of points: {}\n".format(len(points_xyz))) | |
| for j, (p, c) in enumerate(zip(points_xyz, points_rgb)): | |
| f.write(f"{j + 1} {p[0]:.8f} {p[1]:.8f} {p[2]:.8f} " | |
| f"{int(c[0])} {int(c[1])} {int(c[2])} 0\n") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| import argparse | |
| import math | |
| import os | |
| from pathlib import Path | |
| from typing import Tuple | |
| import numpy as np | |
| from PIL import Image | |
| from colmap_export import (rotmat_to_quat, c2w_gl_to_colmap_w2c, | |
| sample_surface_points, reprojection_errors, | |
| write_colmap_text) | |
| # ----------------------------- | |
| # Utilities | |
| # ----------------------------- | |
| def clamp(x, lo, hi): | |
| return max(lo, min(hi, x)) | |
| def normalize_np(v: np.ndarray, eps: float = 1e-12) -> np.ndarray: | |
| n = np.linalg.norm(v) | |
| if n < eps: | |
| return v | |
| return v / n | |
| def look_at_c2w(eye: np.ndarray, target: np.ndarray, up: np.ndarray) -> np.ndarray: | |
| """Return camera-to-world 4x4 matrix, right-handed, camera looks along -Z.""" | |
| forward = normalize_np(target - eye) | |
| right = normalize_np(np.cross(forward, up)) | |
| true_up = np.cross(right, forward) | |
| c2w = np.eye(4, dtype=np.float64) | |
| # Camera axes in world space: | |
| # +X = right, +Y = true_up, +Z = -forward (because camera looks down -Z) | |
| c2w[:3, 0] = right | |
| c2w[:3, 1] = true_up | |
| c2w[:3, 2] = -forward | |
| c2w[:3, 3] = eye | |
| return c2w | |
| def c2w_to_w2c(c2w: np.ndarray) -> np.ndarray: | |
| return np.linalg.inv(c2w) | |
| def srgb_encode(x: np.ndarray) -> np.ndarray: | |
| x = np.clip(x, 0.0, 1.0) | |
| a = 0.055 | |
| out = np.where(x <= 0.0031308, 12.92 * x, (1.0 + a) * np.power(x, 1.0 / 2.4) - a) | |
| return np.clip(out, 0.0, 1.0) | |
| # ----------------------------- | |
| # Taichi setup | |
| # ----------------------------- | |
| import taichi as ti | |
| try: | |
| ti.init(arch=ti.cuda, default_fp=ti.f32) | |
| except Exception: | |
| ti.init(arch=ti.gpu, default_fp=ti.f32) | |
| except Exception: | |
| ti.init(arch=ti.cpu, default_fp=ti.f32) | |
| vec3 = ti.types.vector(3, ti.f32) | |
| # Global config fields (shape=()) | |
| width_f = ti.field(dtype=ti.i32, shape=()) | |
| height_f = ti.field(dtype=ti.i32, shape=()) | |
| fov_y_deg_f = ti.field(dtype=ti.f32, shape=()) | |
| camera_radius_f = ti.field(dtype=ti.f32, shape=()) | |
| camera_elev_deg_f = ti.field(dtype=ti.f32, shape=()) | |
| camera_target = ti.Vector.field(3, dtype=ti.f32, shape=()) | |
| camera_pos = ti.Vector.field(3, dtype=ti.f32, shape=()) | |
| camera_right = ti.Vector.field(3, dtype=ti.f32, shape=()) | |
| camera_up = ti.Vector.field(3, dtype=ti.f32, shape=()) | |
| camera_forward = ti.Vector.field(3, dtype=ti.f32, shape=()) | |
| img = ti.Vector.field(3, dtype=ti.f32, shape=()) | |
| # Per-pixel primary-ray hits: [hit_flag, x, y, z]. Traced at a reduced | |
| # resolution to sample the initial point cloud; reallocated in main(). | |
| hit_map = ti.Vector.field(4, dtype=ti.f32, shape=()) | |
| # Small constants | |
| PI = 3.1415926535897932384626433832795 | |
| @ti.func | |
| def colormap_turbo(t: ti.f32) -> vec3: | |
| c = vec3(0.0, 0.0, 0.0) | |
| c = c * t + vec3(-266.03287948, -63.82163439, -97.24397473) # k=7 | |
| c = c * t + vec3(874.85901369, 202.98596195, 447.99287080) # k=6 | |
| c = c * t + vec3(-1061.31232675, -245.26823636, -766.82678386) # k=5 | |
| c = c * t + vec3(550.44382083, 149.40813531, 604.07329733) # k=4 | |
| c = c * t + vec3(-90.46877071, -55.09180383, -203.76964931) # k=3 | |
| c = c * t + vec3(-10.15061040, 9.64581379, 9.26380342) # k=2 | |
| c = c * t + vec3(2.94711014, 2.06520532, 6.33792818) # k=1 | |
| c = c * t + vec3(0.14637796, 0.08594198, 0.23431523) # k=0 | |
| return c | |
| @ti.func | |
| def colormap_viridis(t: ti.f32) -> vec3: | |
| c = vec3(0.0, 0.0, 0.0) | |
| c = c * t + vec3(2.89151588, -0.17213704, 0.17750210) # k=3 | |
| c = c * t + vec3(-2.14339482, -0.16754949, -1.71784815) # k=2 | |
| c = c * t + vec3(0.04824199, 1.23905227, 1.23133794) # k=1 | |
| c = c * t + vec3(0.29387218, 0.01752027, 0.34360395) # k=0 | |
| return c | |
| @ti.func | |
| def colormap_magma(t: ti.f32) -> vec3: | |
| c = vec3(0.0, 0.0, 0.0) | |
| c = c * t + vec3(1.69082060, -2.36381252, 0.75796302) # k=4 | |
| c = c * t + vec3(-5.49016037, 5.48987916, 3.89534203) # k=3 | |
| c = c * t + vec3(4.30604404, -2.93736985, -7.52578597) # k=2 | |
| c = c * t + vec3(0.45897387, 0.82685306, 3.73227094) # k=1 | |
| c = c * t + vec3(-0.00157294, -0.01042786, -0.05490989) # k=0 | |
| return c | |
| @ti.func | |
| def colormap_inferno(t: ti.f32) -> vec3: | |
| c = vec3(0.0, 0.0, 0.0) | |
| c = c * t + vec3(1.66959821, -2.07978236, 7.14515755) # k=4 | |
| c = c * t + vec3(-5.11462265, 4.46548484, -6.74156149) # k=3 | |
| c = c * t + vec3(3.64171238, -1.86975118, -2.30104808) # k=2 | |
| c = c * t + vec3(0.74909880, 0.51010061, 2.59796038) # k=1 | |
| c = c * t + vec3(-0.01087222, -0.00208239, 0.00011545) # k=0 | |
| return c | |
| @ti.func | |
| def colormap_cividis(t: ti.f32) -> vec3: | |
| c = vec3(0.0, 0.0, 0.0) | |
| c = c * t + vec3(0.59905315, 0.14530728, -0.89703048) # k=3 | |
| c = c * t + vec3(-0.95976947, -0.07321042, 0.66155846) # k=2 | |
| c = c * t + vec3(1.43486697, 0.69925032, 0.05514949) # k=1 | |
| c = c * t + vec3(-0.05784352, 0.13417334, 0.38674306) # k=0 | |
| return c | |
| @ti.func | |
| def saturate(x): | |
| return ti.min(ti.max(x, 0.0), 1.0) | |
| @ti.func | |
| def hash_u32(x): | |
| x ^= x >> 16 | |
| x *= ti.u32(0x7feb352d) | |
| x ^= x >> 15 | |
| x *= ti.u32(0x846ca68b) | |
| x ^= x >> 16 | |
| return x | |
| @ti.func | |
| def rand01(seed): | |
| seed = hash_u32(seed) | |
| return ti.cast(seed & 0x00FFFFFF, ti.f32) / ti.cast(0x01000000, ti.f32) | |
| @ti.func | |
| def rand2(seed): | |
| return ti.Vector([rand01(seed), rand01(seed ^ ti.u32(0x9e3779b9))]) | |
| @ti.func | |
| def mix(a, b, t): | |
| return a * (1.0 - t) + b * t | |
| SURFACE_EPS = 1e-5 | |
| BOUNDING_RADIUS = 2.0 | |
| @ti.func | |
| def mandelbulb_implicit(p: vec3) -> ti.types.vector(2, ti.f32): | |
| z = p | |
| dr = 1.0 | |
| r = 0.0 | |
| power = 2 | |
| min_radius = 1e9 | |
| for i in range(64): | |
| r = z.norm() | |
| min_radius = ti.min(min_radius, r) | |
| if r > 64.0: | |
| break | |
| r = ti.max(r, 1e-8) | |
| theta = ti.acos(ti.max(-1.0, ti.min(1.0, z.z / r))) | |
| phi = ti.atan2(z.y, z.x) | |
| zr = ti.pow(r, power) | |
| dr = ti.pow(r, power - 1.0) * power * dr + 1.0 | |
| theta *= power | |
| phi *= power | |
| sin_t = ti.sin(theta) | |
| z1 = zr * vec3([sin_t * ti.cos(phi), sin_t * ti.sin(phi), ti.cos(theta)]) + p | |
| r1 = z1.norm() | |
| z = z1 | |
| r = z.norm() | |
| r = ti.max(r, 1e-8) | |
| de = 0.5 * ti.log(r) * r / dr | |
| #de = ti.abs(de) | |
| return ti.Vector([de, min_radius]) | |
| @ti.func | |
| def sphere_clip(ro: vec3, rd: vec3, radius: ti.f32) -> ti.types.vector(3, ti.f32): | |
| b = ro.dot(rd) | |
| c = ro.dot(ro) - radius * radius | |
| disc = b * b - c | |
| result = ti.Vector([0.0, 0.0, 0.0]) | |
| if disc >= 0.0: | |
| sqrt_disc = ti.sqrt(disc) | |
| result = ti.Vector([1.0, -b - sqrt_disc, -b + sqrt_disc]) | |
| return result | |
| @ti.func | |
| def estimate_normal(p: vec3) -> vec3: | |
| e = 5e-5 | |
| ex = vec3([e, 0.0, 0.0]) | |
| ey = vec3([0.0, e, 0.0]) | |
| ez = vec3([0.0, 0.0, e]) | |
| dx1 = mandelbulb_implicit(p + ex)[0] | |
| dx0 = mandelbulb_implicit(p - ex)[0] | |
| dy1 = mandelbulb_implicit(p + ey)[0] | |
| dy0 = mandelbulb_implicit(p - ey)[0] | |
| dz1 = mandelbulb_implicit(p + ez)[0] | |
| dz0 = mandelbulb_implicit(p - ez)[0] | |
| n = vec3([dx1 - dx0, dy1 - dy0, dz1 - dz0]) | |
| return n / e | |
| @ti.func | |
| def random_hemisphere_cosine(n: vec3, seed: ti.u32) -> vec3: | |
| r = rand2(seed) | |
| phi = 2.0 * PI * r.x | |
| cos_theta = ti.sqrt(1.0 - r.y) | |
| sin_theta = ti.sqrt(r.y) | |
| local = vec3([ti.cos(phi) * sin_theta, ti.sin(phi) * sin_theta, cos_theta]) | |
| # Build orthonormal basis | |
| tangent = ti.Vector([0.0, 1.0, 0.0]) | |
| if ti.abs(n.y) > 0.999: | |
| tangent = ti.Vector([1.0, 0.0, 0.0]) | |
| tangent = (tangent - n * tangent.dot(n)).normalized() | |
| bitangent = n.cross(tangent) | |
| return (tangent * local.x + bitangent * local.y + n * local.z).normalized() | |
| @ti.func | |
| def sky_radiance(rd: vec3) -> vec3: | |
| # t = 0.5 * (rd.y + 1.0) | |
| t = max(rd.normalized().y, 0.0) | |
| sky_bottom = ti.Vector([0.7, 0.75, 0.8]) ** 2.2 | |
| sky_top = ti.Vector([1.2, 1.4, 1.8]) ** 2.2 | |
| col = mix(sky_bottom, sky_top, t) | |
| return col | |
| @ti.func | |
| def trace_scene(ro: vec3, rd: vec3, seed: ti.u32) -> ti.types.vector(5, ti.f32): | |
| # based on https://raw.githubusercontent.com/harry7557558/spirulae/refs/heads/master/implicit3-rt/frag-render.glsl | |
| rd_n = rd.normalized() | |
| result = ti.Vector([0.0, 0.0, ro.x, ro.y, ro.z]) | |
| clip = sphere_clip(ro, rd_n, BOUNDING_RADIUS) | |
| t0c = ti.max(clip[1], 0.0) | |
| t1c = clip[2] | |
| if clip[0] >= 0.5 and t1c >= t0c: | |
| step_sz = 500.0*SURFACE_EPS | |
| t = t0c | |
| dt = step_sz | |
| v = 0.0 | |
| v0 = 0.0 | |
| v00 = 0.0 | |
| v1 = 0.0 | |
| dt0 = 0.0 | |
| dt00 = 0.0 | |
| is_bisecting = 0 | |
| prev_t = t0c | |
| t_hi = 0.0 | |
| tfar = t1c | |
| found = 0 | |
| result_t = 0.0 | |
| for iteration in range(16384): | |
| if found == 1: | |
| break | |
| if t > tfar: | |
| break | |
| val_mr = mandelbulb_implicit(ro + rd_n * t) - 20.0*SURFACE_EPS | |
| v = val_mr[0] | |
| if is_bisecting == 1: | |
| if t_hi - prev_t <= 1e-5: | |
| dv = v1 - v0 | |
| if ti.abs(dv) > 1e-12: | |
| result_t = prev_t - v0 / dv * (t_hi - prev_t) | |
| else: | |
| result_t = 0.5 * (prev_t + t_hi) | |
| found = 1 | |
| else: | |
| if v * v0 < 0.0: | |
| t_hi = t | |
| v1 = v | |
| else: | |
| prev_t = t | |
| v0 = v | |
| t = 0.5 * (prev_t + t_hi) | |
| elif v * v0 < 0.0: | |
| is_bisecting = 1 | |
| t_hi = t | |
| v1 = v | |
| t = 0.5 * (prev_t + t_hi) | |
| else: | |
| if ti.math.isnan(dt0) or dt0 <= 0.0: | |
| v00 = v | |
| v0 = v | |
| dt0 = 0.0 | |
| dt00 = 0.0 | |
| g = 0.0 | |
| if dt0 > 0.0: | |
| if dt00 > 0.0: | |
| g = (v00 * dt0 / (dt00 * (dt0 + dt00)) | |
| - v0 * (dt0 + dt00) / (dt0 * dt00) | |
| + v * (2.0 * dt0 + dt00) / (dt0 * (dt0 + dt00))) | |
| else: | |
| g = (v - v0) / dt0 | |
| dt00 = dt0 | |
| dt0 = dt | |
| prev_t = t | |
| v00 = v0 | |
| v0 = v | |
| ddt = ti.abs(v / g) | |
| if ddt > step_sz or ti.math.isnan(ddt) or ti.math.isinf(ddt): | |
| dt = step_sz | |
| else: | |
| dt_cand = ti.min(ddt - step_sz, tfar - prev_t - 0.01 * step_sz) | |
| dt = ti.max(0.05 * step_sz, ti.min(dt_cand, step_sz)) | |
| if iteration < 2: | |
| dt *= rand01(seed + iteration) | |
| t += dt | |
| if found == 1: | |
| p = ro + rd_n * result_t | |
| result = ti.Vector([1.0, result_t, p.x, p.y, p.z]) | |
| return result | |
| @ti.func | |
| def trace_scene_slow(ro: vec3, rd: vec3, seed: ti.u32) -> ti.types.vector(5, ti.f32): | |
| """ | |
| Sphere tracing. | |
| Returns [hit_flag, t, p.x, p.y, p.z] with hit_flag in {0,1}. | |
| """ | |
| t = 0.0 | |
| hit = 0.0 | |
| p = ro | |
| for i in range(16384): | |
| p = ro + rd * t | |
| sdf = 0.25 * mandelbulb_implicit(p)[0] | |
| if sdf < SURFACE_EPS: | |
| hit = ti.f32(i) | |
| break | |
| t += sdf | |
| if t > 100.0: | |
| break | |
| return ti.Vector([hit, t, p.x, p.y, p.z]) | |
| @ti.func | |
| def direct_light(n: vec3, p: vec3, view_dir: vec3, seed: ti.u32) -> vec3: | |
| # One sun light with a hard shadow ray (cheap, but useful for contrast). | |
| ldir = -ti.Vector([-0.4, 0.8, -0.2]).normalized() | |
| ndotl = saturate(n.dot(ldir)) | |
| return_val = ti.Vector([0.0, 0.0, 0.0]) | |
| if ndotl > 0.0: | |
| # Shadow ray | |
| shadow_ro = p + n * (SURFACE_EPS * 4.0) | |
| shadow_hit = trace_scene(shadow_ro, ldir, seed) | |
| visible = 1.0 if shadow_hit[0] == 0.0 else 0.0 | |
| # Simple specular-like glint to help structure read well. | |
| h = (ldir + view_dir).normalized() | |
| spec = ti.pow(saturate(n.dot(h)), 96.0) * 0.15 | |
| sun_color = 1.0 * ti.Vector([1.0, 0.95, 0.9]) | |
| return_val = visible * (ndotl + spec) * sun_color | |
| return return_val | |
| @ti.func | |
| def shade_background(rd: vec3, throughput: vec3, seed: ti.u32) -> vec3: | |
| # Add a subtle fog / aerial perspective based on ray direction. | |
| c = sky_radiance(rd) | |
| return throughput * c | |
| @ti.func | |
| def render_path(ro: vec3, rd: vec3, px_seed: ti.u32) -> vec3: | |
| throughput = ti.Vector([1.0, 1.0, 1.0]) | |
| radiance = ti.Vector([0.0, 0.0, 0.0]) | |
| cur_ro = ro | |
| cur_rd = rd | |
| for bounce in range(4): | |
| hit = trace_scene(cur_ro, cur_rd, px_seed + bounce * 131 + 17) | |
| if hit[0] == 0.0: | |
| if bounce > 0: | |
| radiance += shade_background(cur_rd, throughput, px_seed + bounce * 131) | |
| break | |
| t = hit[1] | |
| p = vec3([hit[2], hit[3], hit[4]]) | |
| n = estimate_normal(p) | |
| n_norm = n.norm() | |
| n = n.normalized() | |
| # Material: mostly diffuse with a tiny amount of view-dependent sheen. | |
| # albedo = (0.5 + 0.35 * ti.sin(1.0 * PI * n) + 0.1 * ti.sin(4.0 * PI * n)) | |
| albedo = colormap_magma(0.5 + 0.5 * ti.cos(ti.log(0.25*n_norm))) | |
| # albedo = ti.max(1.0 - 1.5 * (1.0 - albedo), 0.0) | |
| # albedo = albedo ** 2.0 | |
| # albedo = albedo + (ti.Vector([0.1, 0.0, 0.2]) - albedo) * (1.0 - ti.exp(-0.01 * hit[0])) | |
| # albedo = albedo + (ti.Vector([0.0, 0.0, 0.0]) - albedo) * (1.0 - ti.exp(-0.1 / n_norm)) | |
| albedo = 0.9 * albedo ** 2.2 | |
| radiance += throughput * albedo * direct_light(n, p, -cur_rd, px_seed + bounce * 977 + 123) | |
| new_dir = random_hemisphere_cosine(n, px_seed + bounce * 977 + 17) | |
| cur_ro = p + n * (SURFACE_EPS * 8.0) | |
| cur_rd = new_dir | |
| throughput *= albedo | |
| # Russian roulette | |
| if bounce >= 2: | |
| p_rr = ti.min(0.95, ti.max(throughput.x, ti.max(throughput.y, throughput.z))) | |
| if rand01(px_seed + bounce * 31337 + 123) > p_rr: | |
| break | |
| throughput /= p_rr | |
| # A tiny ambient term to avoid dead-black crevices. | |
| # radiance += throughput * ti.Vector([0.01, 0.01, 0.015]) | |
| return radiance | |
| @ti.func | |
| def is_finite(x): | |
| return (not ti.math.isinf(x)) and (not ti.math.isnan(x)) | |
| @ti.kernel | |
| def render_kernel(view_idx: ti.i32, frame_seed: ti.i32, spp: ti.i32): | |
| w = width_f[None] | |
| h = height_f[None] | |
| fov = fov_y_deg_f[None] * PI / 180.0 | |
| aspect = ti.cast(w, ti.f32) / ti.cast(h, ti.f32) | |
| tan_half = ti.tan(0.5 * fov) | |
| cam_pos = camera_pos[None] | |
| cam_right = camera_right[None] | |
| cam_up = camera_up[None] | |
| cam_forward = camera_forward[None] | |
| for y, x in img: | |
| color = ti.Vector([0.0, 0.0, 0.0]) | |
| total = 0.0 | |
| for s in range(spp): | |
| seed = ti.u32(frame_seed * 1315423911 + view_idx * ti.u32(2654435761) + y * 92821 + x * 68917 + s * 97531) | |
| # Jittered pixel center. | |
| j = rand2(seed) | |
| px = (ti.cast(x, ti.f32) + j.x) / ti.cast(w, ti.f32) | |
| py = (ti.cast(y, ti.f32) + j.y) / ti.cast(h, ti.f32) | |
| ndc_x = (2.0 * px - 1.0) * aspect * tan_half | |
| ndc_y = (1.0 - 2.0 * py) * tan_half | |
| rd = (cam_forward + cam_right * ndc_x + cam_up * ndc_y).normalized() | |
| ro = cam_pos | |
| color_d = render_path(ro, rd, seed) | |
| if is_finite(color_d.norm()): | |
| color += color_d | |
| total += 1.0 | |
| color /= ti.max(total, 1.0) | |
| img[y, x] = color | |
| @ti.kernel | |
| def primary_hit_kernel(w: ti.i32, h: ti.i32): | |
| fov = fov_y_deg_f[None] * PI / 180.0 | |
| aspect = ti.cast(w, ti.f32) / ti.cast(h, ti.f32) | |
| tan_half = ti.tan(0.5 * fov) | |
| cam_pos = camera_pos[None] | |
| cam_right = camera_right[None] | |
| cam_up = camera_up[None] | |
| cam_forward = camera_forward[None] | |
| for y, x in hit_map: | |
| px = (ti.cast(x, ti.f32) + 0.5) / ti.cast(w, ti.f32) | |
| py = (ti.cast(y, ti.f32) + 0.5) / ti.cast(h, ti.f32) | |
| ndc_x = (2.0 * px - 1.0) * aspect * tan_half | |
| ndc_y = (1.0 - 2.0 * py) * tan_half | |
| rd = (cam_forward + cam_right * ndc_x + cam_up * ndc_y).normalized() | |
| hit = trace_scene(cam_pos, rd, ti.u32(y * 92821 + x * 68917 + 1)) | |
| hit_map[y, x] = ti.Vector([hit[0], hit[2], hit[3], hit[4]]) | |
| def configure_scene(args) -> None: | |
| width_f[None] = args.width | |
| height_f[None] = args.height | |
| fov_y_deg_f[None] = args.fov_y_deg | |
| camera_radius_f[None] = args.camera_radius | |
| camera_elev_deg_f[None] = args.camera_elevation_deg | |
| camera_target[None] = ti.Vector([args.target_x, args.target_y, args.target_z]) | |
| def set_camera_from_pose(c2w: np.ndarray) -> None: | |
| camera_pos[None] = ti.Vector(c2w[:3, 3].astype(np.float32)) | |
| camera_right[None] = ti.Vector(c2w[:3, 0].astype(np.float32)) | |
| camera_up[None] = ti.Vector(c2w[:3, 1].astype(np.float32)) | |
| # Camera forward in world space is -Z axis of c2w | |
| camera_forward[None] = ti.Vector((-c2w[:3, 2]).astype(np.float32)) | |
| def generate_orbit_pose(view_idx: int, num_views: int, args) -> Tuple[np.ndarray, np.ndarray]: | |
| # --- Fibonacci sphere formula --- | |
| i = view_idx | |
| N = num_views | |
| # y coordinate goes from +1 to -1 | |
| y = 1.0 - 2.0 * (i + 0.5) / N | |
| radius_xy = math.sqrt(max(0.0, 1.0 - y * y)) | |
| golden_angle = math.pi * (3.0 - math.sqrt(5.0)) | |
| theta = golden_angle * i | |
| x = radius_xy * math.cos(theta) | |
| z = radius_xy * math.sin(theta) | |
| # --- Camera radius --- | |
| r = args.camera_radius | |
| # Direction = normalized Fibonacci point | |
| direction = np.array([x, y, z], dtype=np.float64) | |
| # --- Camera target --- | |
| target = np.array([args.target_x, args.target_y, args.target_z], dtype=np.float64) | |
| # --- Compute camera position --- | |
| eye = target + r * direction | |
| # --- Up vector (can adjust if needed based on hemisphere) --- | |
| up = np.array([0.0, 1.0, 0.0], dtype=np.float64) | |
| # --- Build transforms --- | |
| c2w = look_at_c2w(eye, target, up) | |
| w2c = c2w_to_w2c(c2w) | |
| return c2w, w2c | |
| def save_image(path: Path, linear_rgb: np.ndarray) -> np.ndarray: | |
| srgb = srgb_encode(linear_rgb) | |
| img8 = (np.clip(srgb, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8) | |
| Image.fromarray(img8).save(path) | |
| return img8 | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Render a Mandelbulb dataset in COLMAP format.") | |
| parser.add_argument("--out_dir", type=str, default="./mandelbulb_colmap") | |
| parser.add_argument("--width", type=int, default=3840) | |
| parser.add_argument("--height", type=int, default=3840) | |
| parser.add_argument("--views", type=int, default=200) | |
| parser.add_argument("--fov_y_deg", type=float, default=50.0) | |
| parser.add_argument("--spp", type=int, default=256) | |
| parser.add_argument("--camera_radius", type=float, default=5.0) | |
| parser.add_argument("--camera_elevation_deg", type=float, default=20.0) | |
| parser.add_argument("--camera_azimuth_offset_deg", type=float, default=0.0) | |
| parser.add_argument("--target_x", type=float, default=0.0) | |
| parser.add_argument("--target_y", type=float, default=0.0) | |
| parser.add_argument("--target_z", type=float, default=0.0) | |
| parser.add_argument("--seed", type=int, default=0) | |
| parser.add_argument("--num_points", type=int, default=100000, | |
| help="target size of the initial point cloud") | |
| parser.add_argument("--point_trace_res", type=int, default=800, | |
| help="max side of the ray grid used to sample surface points") | |
| parser.add_argument("--skip_render", action="store_true", | |
| help="reuse existing images/ (pass the same camera args as the " | |
| "original render) and only rebuild sparse/0") | |
| args = parser.parse_args() | |
| np.random.seed(args.seed) | |
| rng = np.random.default_rng(args.seed) | |
| configure_scene(args) | |
| global img, hit_map | |
| img = ti.Vector.field(3, dtype=ti.f32, shape=(args.height, args.width)) | |
| scale = min(1.0, args.point_trace_res / max(args.width, args.height)) | |
| trace_w = max(1, round(args.width * scale)) | |
| trace_h = max(1, round(args.height * scale)) | |
| hit_map = ti.Vector.field(4, dtype=ti.f32, shape=(trace_h, trace_w)) | |
| out_dir = Path(args.out_dir) | |
| images_dir = out_dir / "images" | |
| images_dir.mkdir(parents=True, exist_ok=True) | |
| # COLMAP camera model: SIMPLE_PINHOLE with fx = fy = f, cx = w/2, cy = h/2. | |
| # fov_y_deg is the vertical FOV, so f is derived from the image height. | |
| f = 0.5 * args.height / math.tan(0.5 * math.radians(args.fov_y_deg)) | |
| camera_id = 1 | |
| cameras = [(camera_id, "SIMPLE_PINHOLE", args.width, args.height, [f, args.width * 0.5, args.height * 0.5])] | |
| images_meta = [] | |
| pts_per_view = max(1, -(-args.num_points // args.views)) | |
| all_pts, all_rgb = [], [] | |
| max_reproj = 0.0 | |
| print(f"Rendering {args.views} views to {out_dir} ...") | |
| for i in range(args.views): | |
| c2w, _ = generate_orbit_pose(i, args.views, args) | |
| set_camera_from_pose(c2w) | |
| img_name = f"{i:06d}.jpg" | |
| img_path = images_dir / img_name | |
| if args.skip_render: | |
| if not img_path.is_file(): | |
| raise SystemExit(f"--skip_render: missing {img_path}") | |
| with Image.open(img_path) as im: | |
| img8 = np.asarray(im.convert("RGB")) | |
| else: | |
| render_kernel(i, args.seed, args.spp) | |
| img8 = save_image(img_path, img.to_numpy()) | |
| # Sample ground-truth surface points for the initial point cloud. | |
| primary_hit_kernel(trace_w, trace_h) | |
| pts, rgb, pix = sample_surface_points(hit_map.to_numpy(), img8, | |
| pts_per_view, rng) | |
| all_pts.append(pts) | |
| all_rgb.append(rgb) | |
| R, t = c2w_gl_to_colmap_w2c(c2w) | |
| q = rotmat_to_quat(R) | |
| images_meta.append((i + 1, q, t, camera_id, img_name)) | |
| if len(pts): | |
| err = reprojection_errors(pts, pix, R, t, f, f, | |
| args.width * 0.5, args.height * 0.5) | |
| max_reproj = max(max_reproj, float(err.max())) | |
| print(f" wrote {img_name} ({len(pts)} surface points)") | |
| points_xyz = np.concatenate(all_pts) if all_pts else np.zeros((0, 3)) | |
| points_rgb = np.concatenate(all_rgb) if all_rgb else np.zeros((0, 3), np.uint8) | |
| write_colmap_text(out_dir, cameras, images_meta, points_xyz, points_rgb) | |
| print(f"Wrote sparse/0: {len(images_meta)} images, {len(points_xyz)} points") | |
| print(f"Max reprojection error of sampled surface points: {max_reproj:.3f} px") | |
| if max_reproj > 2.0: | |
| print("WARNING: large reprojection error; exported poses may be " | |
| "inconsistent with the rendered images.") | |
| print("Done.") | |
| if __name__ == "__main__": | |
| main() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| import argparse | |
| import math | |
| import os | |
| from pathlib import Path | |
| from typing import Tuple | |
| import numpy as np | |
| from PIL import Image | |
| from colmap_export import (rotmat_to_quat, c2w_gl_to_colmap_w2c, | |
| sample_surface_points, reprojection_errors, | |
| write_colmap_text) | |
| # ----------------------------- | |
| # Utilities | |
| # ----------------------------- | |
| def clamp(x, lo, hi): | |
| return max(lo, min(hi, x)) | |
| def normalize_np(v: np.ndarray, eps: float = 1e-12) -> np.ndarray: | |
| n = np.linalg.norm(v) | |
| if n < eps: | |
| return v | |
| return v / n | |
| def look_at_c2w(eye: np.ndarray, target: np.ndarray, up: np.ndarray) -> np.ndarray: | |
| """Return camera-to-world 4x4 matrix, right-handed, camera looks along -Z.""" | |
| forward = normalize_np(target - eye) | |
| right = normalize_np(np.cross(forward, up)) | |
| true_up = np.cross(right, forward) | |
| c2w = np.eye(4, dtype=np.float64) | |
| # Camera axes in world space: | |
| # +X = right, +Y = true_up, +Z = -forward (because camera looks down -Z) | |
| c2w[:3, 0] = right | |
| c2w[:3, 1] = true_up | |
| c2w[:3, 2] = -forward | |
| c2w[:3, 3] = eye | |
| return c2w | |
| def c2w_to_w2c(c2w: np.ndarray) -> np.ndarray: | |
| return np.linalg.inv(c2w) | |
| def srgb_encode(x: np.ndarray) -> np.ndarray: | |
| x = np.clip(x, 0.0, 1.0) | |
| a = 0.055 | |
| out = np.where(x <= 0.0031308, 12.92 * x, (1.0 + a) * np.power(x, 1.0 / 2.4) - a) | |
| return np.clip(out, 0.0, 1.0) | |
| # ----------------------------- | |
| # Taichi setup | |
| # ----------------------------- | |
| import taichi as ti | |
| try: | |
| ti.init(arch=ti.cuda, default_fp=ti.f32) | |
| except Exception: | |
| ti.init(arch=ti.gpu, default_fp=ti.f32) | |
| except Exception: | |
| ti.init(arch=ti.cpu, default_fp=ti.f32) | |
| vec3 = ti.types.vector(3, ti.f32) | |
| # Global config fields (shape=()) | |
| width_f = ti.field(dtype=ti.i32, shape=()) | |
| height_f = ti.field(dtype=ti.i32, shape=()) | |
| fov_y_deg_f = ti.field(dtype=ti.f32, shape=()) | |
| camera_radius_f = ti.field(dtype=ti.f32, shape=()) | |
| camera_elev_deg_f = ti.field(dtype=ti.f32, shape=()) | |
| camera_target = ti.Vector.field(3, dtype=ti.f32, shape=()) | |
| camera_pos = ti.Vector.field(3, dtype=ti.f32, shape=()) | |
| camera_right = ti.Vector.field(3, dtype=ti.f32, shape=()) | |
| camera_up = ti.Vector.field(3, dtype=ti.f32, shape=()) | |
| camera_forward = ti.Vector.field(3, dtype=ti.f32, shape=()) | |
| img = ti.Vector.field(3, dtype=ti.f32, shape=()) | |
| # Per-pixel primary-ray hits: [hit_flag, x, y, z]. Traced at a reduced | |
| # resolution to sample the initial point cloud; reallocated in main(). | |
| hit_map = ti.Vector.field(4, dtype=ti.f32, shape=()) | |
| # Small constants | |
| PI = 3.1415926535897932384626433832795 | |
| @ti.func | |
| def colormap_turbo(t: ti.f32) -> vec3: | |
| c = vec3(0.0, 0.0, 0.0) | |
| c = c * t + vec3(-266.03287948, -63.82163439, -97.24397473) # k=7 | |
| c = c * t + vec3(874.85901369, 202.98596195, 447.99287080) # k=6 | |
| c = c * t + vec3(-1061.31232675, -245.26823636, -766.82678386) # k=5 | |
| c = c * t + vec3(550.44382083, 149.40813531, 604.07329733) # k=4 | |
| c = c * t + vec3(-90.46877071, -55.09180383, -203.76964931) # k=3 | |
| c = c * t + vec3(-10.15061040, 9.64581379, 9.26380342) # k=2 | |
| c = c * t + vec3(2.94711014, 2.06520532, 6.33792818) # k=1 | |
| c = c * t + vec3(0.14637796, 0.08594198, 0.23431523) # k=0 | |
| return c | |
| @ti.func | |
| def colormap_viridis(t: ti.f32) -> vec3: | |
| c = vec3(0.0, 0.0, 0.0) | |
| c = c * t + vec3(2.89151588, -0.17213704, 0.17750210) # k=3 | |
| c = c * t + vec3(-2.14339482, -0.16754949, -1.71784815) # k=2 | |
| c = c * t + vec3(0.04824199, 1.23905227, 1.23133794) # k=1 | |
| c = c * t + vec3(0.29387218, 0.01752027, 0.34360395) # k=0 | |
| return c | |
| @ti.func | |
| def colormap_magma(t: ti.f32) -> vec3: | |
| c = vec3(0.0, 0.0, 0.0) | |
| c = c * t + vec3(1.69082060, -2.36381252, 0.75796302) # k=4 | |
| c = c * t + vec3(-5.49016037, 5.48987916, 3.89534203) # k=3 | |
| c = c * t + vec3(4.30604404, -2.93736985, -7.52578597) # k=2 | |
| c = c * t + vec3(0.45897387, 0.82685306, 3.73227094) # k=1 | |
| c = c * t + vec3(-0.00157294, -0.01042786, -0.05490989) # k=0 | |
| return c | |
| @ti.func | |
| def colormap_inferno(t: ti.f32) -> vec3: | |
| c = vec3(0.0, 0.0, 0.0) | |
| c = c * t + vec3(1.66959821, -2.07978236, 7.14515755) # k=4 | |
| c = c * t + vec3(-5.11462265, 4.46548484, -6.74156149) # k=3 | |
| c = c * t + vec3(3.64171238, -1.86975118, -2.30104808) # k=2 | |
| c = c * t + vec3(0.74909880, 0.51010061, 2.59796038) # k=1 | |
| c = c * t + vec3(-0.01087222, -0.00208239, 0.00011545) # k=0 | |
| return c | |
| @ti.func | |
| def colormap_cividis(t: ti.f32) -> vec3: | |
| c = vec3(0.0, 0.0, 0.0) | |
| c = c * t + vec3(0.59905315, 0.14530728, -0.89703048) # k=3 | |
| c = c * t + vec3(-0.95976947, -0.07321042, 0.66155846) # k=2 | |
| c = c * t + vec3(1.43486697, 0.69925032, 0.05514949) # k=1 | |
| c = c * t + vec3(-0.05784352, 0.13417334, 0.38674306) # k=0 | |
| return c | |
| @ti.func | |
| def saturate(x): | |
| return ti.min(ti.max(x, 0.0), 1.0) | |
| @ti.func | |
| def hypot(x, y): | |
| return ti.sqrt(x * x + y * y) | |
| @ti.func | |
| def hash_u32(x): | |
| x ^= x >> 16 | |
| x *= ti.u32(0x7feb352d) | |
| x ^= x >> 15 | |
| x *= ti.u32(0x846ca68b) | |
| x ^= x >> 16 | |
| return x | |
| @ti.func | |
| def rand01(seed): | |
| seed = hash_u32(seed) | |
| return ti.cast(seed & 0x00FFFFFF, ti.f32) / ti.cast(0x01000000, ti.f32) | |
| @ti.func | |
| def rand2(seed): | |
| return ti.Vector([rand01(seed), rand01(seed ^ ti.u32(0x9e3779b9))]) | |
| @ti.func | |
| def mix(a, b, t): | |
| return a * (1.0 - t) + b * t | |
| SURFACE_EPS = 1e-4 | |
| BOUNDING_RADIUS = 2.0 | |
| @ti.func | |
| def mandeltorus_implicit(p: vec3) -> ti.types.vector(2, ti.f32): | |
| p *= 2.0 | |
| z = p | |
| dr = 1.0 | |
| r = 0.0 | |
| min_radius = 1e9 | |
| k = 6.0 | |
| m = 6.0 | |
| n = 6.0 | |
| R = 1.5 | |
| # k = 4.0 | |
| # m = 12.0 | |
| # n = 8.0 | |
| # R = 1.5 | |
| niter = 24 | |
| for i in range(niter): | |
| r = z.norm() | |
| min_radius = ti.min(min_radius, r) | |
| if r > 64.0: | |
| niter = i | |
| break | |
| r = ti.max(r, 1e-8) | |
| r0 = hypot(hypot(z.x, z.y) - R, z.z) | |
| rk = r0 ** k | |
| ma = m * ti.atan2(z.y, z.x) | |
| nb = n * ti.atan2(hypot(z.x, z.y) - R, z.z) | |
| z1 = vec3([ | |
| ti.cos(ma) * (R + rk * ti.sin(nb)), | |
| ti.sin(ma) * (R + rk * ti.sin(nb)), | |
| rk * ti.cos(nb) | |
| ]) + p | |
| rk = z1.norm() | |
| z = z1 | |
| dr = ti.pow(r0, k - 1.0) * k * dr + 1.0 # approximation | |
| r = z.norm() | |
| r = ti.max(r, 1e-8) | |
| de = 0.5 * ti.log(r) * r / dr | |
| # de = ti.log(r + 1.0) / (0.375 * niter) - R * (ti.exp(-k) + k - 1.0) / (1.0 + ti.exp(-k)) | |
| #de = ti.abs(de) | |
| return ti.Vector([de, min_radius]) | |
| @ti.func | |
| def sphere_clip(ro: vec3, rd: vec3, radius: ti.f32) -> ti.types.vector(3, ti.f32): | |
| b = ro.dot(rd) | |
| c = ro.dot(ro) - radius * radius | |
| disc = b * b - c | |
| result = ti.Vector([0.0, 0.0, 0.0]) | |
| if disc >= 0.0: | |
| sqrt_disc = ti.sqrt(disc) | |
| result = ti.Vector([1.0, -b - sqrt_disc, -b + sqrt_disc]) | |
| return result | |
| @ti.func | |
| def estimate_normal(p: vec3) -> vec3: | |
| e = 5e-5 | |
| ex = vec3([e, 0.0, 0.0]) | |
| ey = vec3([0.0, e, 0.0]) | |
| ez = vec3([0.0, 0.0, e]) | |
| dx1 = mandeltorus_implicit(p + ex)[0] | |
| dx0 = mandeltorus_implicit(p - ex)[0] | |
| dy1 = mandeltorus_implicit(p + ey)[0] | |
| dy0 = mandeltorus_implicit(p - ey)[0] | |
| dz1 = mandeltorus_implicit(p + ez)[0] | |
| dz0 = mandeltorus_implicit(p - ez)[0] | |
| n = vec3([dx1 - dx0, dy1 - dy0, dz1 - dz0]) | |
| return n / e | |
| @ti.func | |
| def random_hemisphere_cosine(n: vec3, seed: ti.u32) -> vec3: | |
| r = rand2(seed) | |
| phi = 2.0 * PI * r.x | |
| cos_theta = ti.sqrt(1.0 - r.y) | |
| sin_theta = ti.sqrt(r.y) | |
| local = vec3([ti.cos(phi) * sin_theta, ti.sin(phi) * sin_theta, cos_theta]) | |
| # Build orthonormal basis | |
| tangent = ti.Vector([0.0, 1.0, 0.0]) | |
| if ti.abs(n.y) > 0.999: | |
| tangent = ti.Vector([1.0, 0.0, 0.0]) | |
| tangent = (tangent - n * tangent.dot(n)).normalized() | |
| bitangent = n.cross(tangent) | |
| return (tangent * local.x + bitangent * local.y + n * local.z).normalized() | |
| @ti.func | |
| def sky_radiance(rd: vec3) -> vec3: | |
| # t = 0.5 * (rd.y + 1.0) | |
| t = max(rd.normalized().y, 0.0) | |
| sky_bottom = ti.Vector([0.7, 0.75, 0.8]) ** 2.2 | |
| sky_top = ti.Vector([1.2, 1.4, 1.8]) ** 2.2 | |
| col = mix(sky_bottom, sky_top, t) | |
| return col | |
| @ti.func | |
| def trace_scene(ro: vec3, rd: vec3, seed: ti.u32) -> ti.types.vector(5, ti.f32): | |
| # based on https://raw.githubusercontent.com/harry7557558/spirulae/refs/heads/master/implicit3-rt/frag-render.glsl | |
| rd_n = rd.normalized() | |
| result = ti.Vector([0.0, 0.0, ro.x, ro.y, ro.z]) | |
| clip = sphere_clip(ro, rd_n, BOUNDING_RADIUS) | |
| t0c = ti.max(clip[1], 0.0) | |
| t1c = clip[2] | |
| if clip[0] >= 0.5 and t1c >= t0c: | |
| step_sz = 500.0*SURFACE_EPS | |
| t = t0c | |
| dt = step_sz | |
| v = 0.0 | |
| v0 = 0.0 | |
| v00 = 0.0 | |
| v1 = 0.0 | |
| dt0 = 0.0 | |
| dt00 = 0.0 | |
| is_bisecting = 0 | |
| prev_t = t0c | |
| t_hi = 0.0 | |
| tfar = t1c | |
| found = 0 | |
| result_t = 0.0 | |
| for iteration in range(16384): | |
| if found == 1: | |
| break | |
| if t > tfar: | |
| break | |
| val_mr = mandeltorus_implicit(ro + rd_n * t) - 20.0*SURFACE_EPS | |
| v = val_mr[0] | |
| if is_bisecting == 1: | |
| if t_hi - prev_t <= 1e-5: | |
| dv = v1 - v0 | |
| if ti.abs(dv) > 1e-12: | |
| result_t = prev_t - v0 / dv * (t_hi - prev_t) | |
| else: | |
| result_t = 0.5 * (prev_t + t_hi) | |
| found = 1 | |
| else: | |
| if v * v0 < 0.0: | |
| t_hi = t | |
| v1 = v | |
| else: | |
| prev_t = t | |
| v0 = v | |
| t = 0.5 * (prev_t + t_hi) | |
| elif v * v0 < 0.0: | |
| is_bisecting = 1 | |
| t_hi = t | |
| v1 = v | |
| t = 0.5 * (prev_t + t_hi) | |
| else: | |
| if ti.math.isnan(dt0) or dt0 <= 0.0: | |
| v00 = v | |
| v0 = v | |
| dt0 = 0.0 | |
| dt00 = 0.0 | |
| g = 0.0 | |
| if dt0 > 0.0: | |
| if dt00 > 0.0: | |
| g = (v00 * dt0 / (dt00 * (dt0 + dt00)) | |
| - v0 * (dt0 + dt00) / (dt0 * dt00) | |
| + v * (2.0 * dt0 + dt00) / (dt0 * (dt0 + dt00))) | |
| else: | |
| g = (v - v0) / dt0 | |
| dt00 = dt0 | |
| dt0 = dt | |
| prev_t = t | |
| v00 = v0 | |
| v0 = v | |
| ddt = ti.abs(v / g) | |
| if ddt > step_sz or ti.math.isnan(ddt) or ti.math.isinf(ddt): | |
| dt = step_sz | |
| else: | |
| dt_cand = ti.min(ddt - step_sz, tfar - prev_t - 0.01 * step_sz) | |
| dt = ti.max(0.05 * step_sz, ti.min(dt_cand, step_sz)) | |
| if iteration < 2: | |
| dt *= rand01(seed + iteration) | |
| t += dt | |
| if found == 1: | |
| p = ro + rd_n * result_t | |
| result = ti.Vector([1.0, result_t, p.x, p.y, p.z]) | |
| return result | |
| @ti.func | |
| def direct_light(n: vec3, p: vec3, view_dir: vec3, seed: ti.u32) -> vec3: | |
| # One sun light with a hard shadow ray (cheap, but useful for contrast). | |
| ldir = -ti.Vector([-0.4, 0.8, -0.2]).normalized() | |
| ndotl = saturate(n.dot(ldir)) | |
| return_val = ti.Vector([0.0, 0.0, 0.0]) | |
| if ndotl > 0.0: | |
| # Shadow ray | |
| shadow_ro = p + n * (SURFACE_EPS * 4.0) | |
| shadow_hit = trace_scene(shadow_ro, ldir, seed) | |
| visible = 1.0 if shadow_hit[0] == 0.0 else 0.0 | |
| # Simple specular-like glint to help structure read well. | |
| h = (ldir + view_dir).normalized() | |
| spec = ti.pow(saturate(n.dot(h)), 96.0) * 0.15 | |
| sun_color = 1.0 * ti.Vector([1.0, 0.95, 0.9]) | |
| return_val = visible * (ndotl + spec) * sun_color | |
| return return_val | |
| @ti.func | |
| def shade_background(rd: vec3, throughput: vec3, seed: ti.u32) -> vec3: | |
| # Add a subtle fog / aerial perspective based on ray direction. | |
| c = sky_radiance(rd) | |
| return throughput * c | |
| @ti.func | |
| def render_path(ro: vec3, rd: vec3, px_seed: ti.u32) -> vec3: | |
| throughput = ti.Vector([1.0, 1.0, 1.0]) | |
| radiance = ti.Vector([0.0, 0.0, 0.0]) | |
| cur_ro = ro | |
| cur_rd = rd | |
| for bounce in range(4): | |
| hit = trace_scene(cur_ro, cur_rd, px_seed + bounce * 131 + 17) | |
| if hit[0] == 0.0: | |
| if bounce > 0: | |
| radiance += shade_background(cur_rd, throughput, px_seed + bounce * 131) | |
| break | |
| t = hit[1] | |
| p = vec3([hit[2], hit[3], hit[4]]) | |
| n = estimate_normal(p) | |
| n_norm = n.norm() | |
| n = n.normalized() | |
| # Material: mostly diffuse with a tiny amount of view-dependent sheen. | |
| # albedo = (0.5 + 0.35 * ti.sin(1.0 * PI * n) + 0.1 * ti.sin(4.0 * PI * n)) | |
| albedo = colormap_turbo(0.5 + 0.5 * ti.cos(ti.log(1.0*n_norm))) | |
| # albedo = ti.max(1.0 - 1.5 * (1.0 - albedo), 0.0) | |
| # albedo = albedo ** 2.0 | |
| # albedo = albedo + (ti.Vector([0.1, 0.0, 0.2]) - albedo) * (1.0 - ti.exp(-0.01 * hit[0])) | |
| # albedo = albedo + (ti.Vector([0.0, 0.0, 0.0]) - albedo) * (1.0 - ti.exp(-0.1 / n_norm)) | |
| albedo = 0.9 * albedo ** 2.2 | |
| radiance += throughput * albedo * direct_light(n, p, -cur_rd, px_seed + bounce * 977 + 123) | |
| new_dir = random_hemisphere_cosine(n, px_seed + bounce * 977 + 17) | |
| cur_ro = p + n * (SURFACE_EPS * 8.0) | |
| cur_rd = new_dir | |
| throughput *= albedo | |
| # Russian roulette | |
| if bounce >= 2: | |
| p_rr = ti.min(0.95, ti.max(throughput.x, ti.max(throughput.y, throughput.z))) | |
| if rand01(px_seed + bounce * 31337 + 123) > p_rr: | |
| break | |
| throughput /= p_rr | |
| # A tiny ambient term to avoid dead-black crevices. | |
| # radiance += throughput * ti.Vector([0.01, 0.01, 0.015]) | |
| return radiance | |
| @ti.func | |
| def is_finite(x): | |
| return (not ti.math.isinf(x)) and (not ti.math.isnan(x)) | |
| @ti.kernel | |
| def render_kernel(view_idx: ti.i32, frame_seed: ti.i32, spp: ti.i32): | |
| w = width_f[None] | |
| h = height_f[None] | |
| fov = fov_y_deg_f[None] * PI / 180.0 | |
| aspect = ti.cast(w, ti.f32) / ti.cast(h, ti.f32) | |
| tan_half = ti.tan(0.5 * fov) | |
| cam_pos = camera_pos[None] | |
| cam_right = camera_right[None] | |
| cam_up = camera_up[None] | |
| cam_forward = camera_forward[None] | |
| for y, x in img: | |
| color = ti.Vector([0.0, 0.0, 0.0]) | |
| total = 0.0 | |
| for s in range(spp): | |
| seed = ti.u32(frame_seed * 1315423911 + view_idx * ti.u32(2654435761) + y * 92821 + x * 68917 + s * 97531) | |
| # Jittered pixel center. | |
| j = rand2(seed) | |
| px = (ti.cast(x, ti.f32) + j.x) / ti.cast(w, ti.f32) | |
| py = (ti.cast(y, ti.f32) + j.y) / ti.cast(h, ti.f32) | |
| ndc_x = (2.0 * px - 1.0) * aspect * tan_half | |
| ndc_y = (1.0 - 2.0 * py) * tan_half | |
| rd = (cam_forward + cam_right * ndc_x + cam_up * ndc_y).normalized() | |
| ro = cam_pos | |
| color_d = render_path(ro, rd, seed) | |
| if is_finite(color_d.norm()): | |
| color += color_d | |
| total += 1.0 | |
| color /= ti.max(total, 1.0) | |
| img[y, x] = color | |
| @ti.kernel | |
| def primary_hit_kernel(w: ti.i32, h: ti.i32): | |
| fov = fov_y_deg_f[None] * PI / 180.0 | |
| aspect = ti.cast(w, ti.f32) / ti.cast(h, ti.f32) | |
| tan_half = ti.tan(0.5 * fov) | |
| cam_pos = camera_pos[None] | |
| cam_right = camera_right[None] | |
| cam_up = camera_up[None] | |
| cam_forward = camera_forward[None] | |
| for y, x in hit_map: | |
| px = (ti.cast(x, ti.f32) + 0.5) / ti.cast(w, ti.f32) | |
| py = (ti.cast(y, ti.f32) + 0.5) / ti.cast(h, ti.f32) | |
| ndc_x = (2.0 * px - 1.0) * aspect * tan_half | |
| ndc_y = (1.0 - 2.0 * py) * tan_half | |
| rd = (cam_forward + cam_right * ndc_x + cam_up * ndc_y).normalized() | |
| hit = trace_scene(cam_pos, rd, ti.u32(y * 92821 + x * 68917 + 1)) | |
| hit_map[y, x] = ti.Vector([hit[0], hit[2], hit[3], hit[4]]) | |
| def configure_scene(args) -> None: | |
| width_f[None] = args.width | |
| height_f[None] = args.height | |
| fov_y_deg_f[None] = args.fov_y_deg | |
| camera_radius_f[None] = args.camera_radius | |
| camera_elev_deg_f[None] = args.camera_elevation_deg | |
| camera_target[None] = ti.Vector([args.target_x, args.target_y, args.target_z]) | |
| def set_camera_from_pose(c2w: np.ndarray) -> None: | |
| camera_pos[None] = ti.Vector(c2w[:3, 3].astype(np.float32)) | |
| camera_right[None] = ti.Vector(c2w[:3, 0].astype(np.float32)) | |
| camera_up[None] = ti.Vector(c2w[:3, 1].astype(np.float32)) | |
| # Camera forward in world space is -Z axis of c2w | |
| camera_forward[None] = ti.Vector((-c2w[:3, 2]).astype(np.float32)) | |
| def generate_orbit_pose(view_idx: int, num_views: int, args) -> Tuple[np.ndarray, np.ndarray]: | |
| # --- Fibonacci sphere formula --- | |
| i = view_idx | |
| N = num_views | |
| # y coordinate goes from +1 to -1 | |
| y = 1.0 - 2.0 * (i + 0.5) / N | |
| radius_xy = math.sqrt(max(0.0, 1.0 - y * y)) | |
| golden_angle = math.pi * (3.0 - math.sqrt(5.0)) | |
| theta = golden_angle * i | |
| x = radius_xy * math.cos(theta) | |
| z = radius_xy * math.sin(theta) | |
| # --- Camera radius --- | |
| r = args.camera_radius | |
| # Direction = normalized Fibonacci point | |
| direction = np.array([x, y, z], dtype=np.float64) | |
| # --- Camera target --- | |
| target = np.array([args.target_x, args.target_y, args.target_z], dtype=np.float64) | |
| # --- Compute camera position --- | |
| eye = target + r * direction | |
| # --- Up vector (can adjust if needed based on hemisphere) --- | |
| up = np.array([0.0, 1.0, 0.0], dtype=np.float64) | |
| # --- Build transforms --- | |
| c2w = look_at_c2w(eye, target, up) | |
| w2c = c2w_to_w2c(c2w) | |
| return c2w, w2c | |
| def save_image(path: Path, linear_rgb: np.ndarray) -> np.ndarray: | |
| srgb = srgb_encode(linear_rgb) | |
| img8 = (np.clip(srgb, 0.0, 1.0) * 255.0 + 0.5).astype(np.uint8) | |
| Image.fromarray(img8).save(path) | |
| return img8 | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Render a 3D fractal.") | |
| parser.add_argument("--out_dir", type=str, default="./mandeltorus_colmap") | |
| parser.add_argument("--width", type=int, default=3840) | |
| parser.add_argument("--height", type=int, default=3840) | |
| parser.add_argument("--views", type=int, default=200) | |
| parser.add_argument("--fov_y_deg", type=float, default=50.0) | |
| parser.add_argument("--spp", type=int, default=256) | |
| parser.add_argument("--camera_radius", type=float, default=5.0) | |
| parser.add_argument("--camera_elevation_deg", type=float, default=20.0) | |
| parser.add_argument("--camera_azimuth_offset_deg", type=float, default=0.0) | |
| parser.add_argument("--target_x", type=float, default=0.0) | |
| parser.add_argument("--target_y", type=float, default=0.0) | |
| parser.add_argument("--target_z", type=float, default=0.0) | |
| parser.add_argument("--seed", type=int, default=0) | |
| parser.add_argument("--num_points", type=int, default=100000, | |
| help="target size of the initial point cloud") | |
| parser.add_argument("--point_trace_res", type=int, default=800, | |
| help="max side of the ray grid used to sample surface points") | |
| parser.add_argument("--skip_render", action="store_true", | |
| help="reuse existing images/ (pass the same camera args as the " | |
| "original render) and only rebuild sparse/0") | |
| args = parser.parse_args() | |
| np.random.seed(args.seed) | |
| rng = np.random.default_rng(args.seed) | |
| configure_scene(args) | |
| global img, hit_map | |
| img = ti.Vector.field(3, dtype=ti.f32, shape=(args.height, args.width)) | |
| scale = min(1.0, args.point_trace_res / max(args.width, args.height)) | |
| trace_w = max(1, round(args.width * scale)) | |
| trace_h = max(1, round(args.height * scale)) | |
| hit_map = ti.Vector.field(4, dtype=ti.f32, shape=(trace_h, trace_w)) | |
| out_dir = Path(args.out_dir) | |
| images_dir = out_dir / "images" | |
| images_dir.mkdir(parents=True, exist_ok=True) | |
| # COLMAP camera model: SIMPLE_PINHOLE with fx = fy = f, cx = w/2, cy = h/2. | |
| # fov_y_deg is the vertical FOV, so f is derived from the image height. | |
| f = 0.5 * args.height / math.tan(0.5 * math.radians(args.fov_y_deg)) | |
| camera_id = 1 | |
| cameras = [(camera_id, "SIMPLE_PINHOLE", args.width, args.height, [f, args.width * 0.5, args.height * 0.5])] | |
| images_meta = [] | |
| pts_per_view = max(1, -(-args.num_points // args.views)) | |
| all_pts, all_rgb = [], [] | |
| max_reproj = 0.0 | |
| print(f"Rendering {args.views} views to {out_dir} ...") | |
| for i in range(args.views): | |
| c2w, _ = generate_orbit_pose(i, args.views, args) | |
| set_camera_from_pose(c2w) | |
| img_name = f"{i:06d}.jpg" | |
| img_path = images_dir / img_name | |
| if args.skip_render: | |
| if not img_path.is_file(): | |
| raise SystemExit(f"--skip_render: missing {img_path}") | |
| with Image.open(img_path) as im: | |
| img8 = np.asarray(im.convert("RGB")) | |
| else: | |
| render_kernel(i, args.seed, args.spp) | |
| img8 = save_image(img_path, img.to_numpy()) | |
| # Sample ground-truth surface points for the initial point cloud. | |
| primary_hit_kernel(trace_w, trace_h) | |
| pts, rgb, pix = sample_surface_points(hit_map.to_numpy(), img8, | |
| pts_per_view, rng) | |
| all_pts.append(pts) | |
| all_rgb.append(rgb) | |
| R, t = c2w_gl_to_colmap_w2c(c2w) | |
| q = rotmat_to_quat(R) | |
| images_meta.append((i + 1, q, t, camera_id, img_name)) | |
| if len(pts): | |
| err = reprojection_errors(pts, pix, R, t, f, f, | |
| args.width * 0.5, args.height * 0.5) | |
| max_reproj = max(max_reproj, float(err.max())) | |
| print(f" wrote {img_name} ({len(pts)} surface points)") | |
| points_xyz = np.concatenate(all_pts) if all_pts else np.zeros((0, 3)) | |
| points_rgb = np.concatenate(all_rgb) if all_rgb else np.zeros((0, 3), np.uint8) | |
| write_colmap_text(out_dir, cameras, images_meta, points_xyz, points_rgb) | |
| print(f"Wrote sparse/0: {len(images_meta)} images, {len(points_xyz)} points") | |
| print(f"Max reprojection error of sampled surface points: {max_reproj:.3f} px") | |
| if max_reproj > 2.0: | |
| print("WARNING: large reprojection error; exported poses may be " | |
| "inconsistent with the rendered images.") | |
| print("Done.") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment