| |
| |
|
|
| import os |
| import sys |
| import types |
| from typing import Optional, Tuple |
|
|
| import numpy as np |
|
|
|
|
| def default_pyopengl_platform() -> str: |
| """Default PyOpenGL platform: empty string on Windows (use system OpenGL), 'egl' on Linux (headless).""" |
| return "" if sys.platform == "win32" else "egl" |
|
|
|
|
| def _ensure_headless_pyrender() -> None: |
| """Mock pyrender.viewer so pyrender can be imported without pyglet/X11 (headless/EGL only).""" |
| if "pyrender.viewer" in sys.modules: |
| return |
| viewer_mod = types.ModuleType("pyrender.viewer") |
| viewer_mod.Viewer = type("Viewer", (), {}) |
| sys.modules["pyrender.viewer"] = viewer_mod |
|
|
|
|
| def set_pyopengl_platform(platform: Optional[str]) -> None: |
| if platform: |
| os.environ.setdefault("PYOPENGL_PLATFORM", platform) |
|
|
|
|
| def look_at(eye: np.ndarray, target: np.ndarray, up: np.ndarray) -> np.ndarray: |
| eye = np.asarray(eye, dtype=np.float32) |
| target = np.asarray(target, dtype=np.float32) |
| up = np.asarray(up, dtype=np.float32) |
|
|
| z_axis = eye - target |
| z_norm = np.linalg.norm(z_axis) |
| if z_norm < 1e-6: |
| z_axis = np.array([0.0, 0.0, 1.0], dtype=np.float32) |
| z_norm = 1.0 |
| z_axis /= z_norm |
|
|
| up = up / (np.linalg.norm(up) + 1e-8) |
| x_axis = np.cross(up, z_axis) |
| x_norm = np.linalg.norm(x_axis) |
| if x_norm < 1e-6: |
| |
| up = np.array([0.0, 1.0, 0.0], dtype=np.float32) |
| if abs(np.dot(up, z_axis)) > 0.9: |
| up = np.array([1.0, 0.0, 0.0], dtype=np.float32) |
| x_axis = np.cross(up, z_axis) |
| x_norm = np.linalg.norm(x_axis) |
| x_axis /= x_norm + 1e-8 |
| y_axis = np.cross(z_axis, x_axis) |
|
|
| T = np.eye(4, dtype=np.float32) |
| T[:3, 0] = x_axis |
| T[:3, 1] = y_axis |
| T[:3, 2] = z_axis |
| T[:3, 3] = eye |
| return T |
|
|
|
|
| def compute_camera_pose(vertices: np.ndarray, cam_dist_scale: float = 2.5) -> np.ndarray: |
| verts = np.asarray(vertices, dtype=np.float32) |
| if not np.all(np.isfinite(verts)): |
| raise ValueError("Vertices contain NaN or inf; cannot compute camera pose.") |
| vmin = verts.min(axis=0) |
| vmax = verts.max(axis=0) |
| center = (vmin + vmax) * 0.5 |
| extent = np.linalg.norm(vmax - vmin) + 1e-6 |
| eye = center + np.array([0.0, 0.0, max(extent * cam_dist_scale, 1.0)], dtype=np.float32) |
| up = np.array([0.0, 1.0, 0.0], dtype=np.float32) |
| return look_at(eye, center, up) |
|
|
|
|
| def _compute_vertex_normals(positions: np.ndarray, faces: np.ndarray) -> np.ndarray: |
| """Area-weighted smooth vertex normals (pure numpy, no trimesh dependency).""" |
| v0 = positions[faces[:, 0]] |
| v1 = positions[faces[:, 1]] |
| v2 = positions[faces[:, 2]] |
| face_normals = np.cross(v1 - v0, v2 - v0) |
| vertex_normals = np.zeros_like(positions) |
| np.add.at(vertex_normals, faces[:, 0], face_normals) |
| np.add.at(vertex_normals, faces[:, 1], face_normals) |
| np.add.at(vertex_normals, faces[:, 2], face_normals) |
| norms = np.linalg.norm(vertex_normals, axis=1, keepdims=True) |
| vertex_normals /= np.maximum(norms, 1e-8) |
| return vertex_normals |
|
|
|
|
| class MeshRenderer: |
| def __init__( |
| self, |
| image_size: int = 512, |
| bg_color: Tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0), |
| focal_length: float = 4000.0, |
| light_intensity: float = 3.0, |
| ): |
| _ensure_headless_pyrender() |
| import pyrender |
|
|
| self.pyrender = pyrender |
| self.image_size = image_size |
| self.renderer = pyrender.OffscreenRenderer( |
| viewport_width=image_size, viewport_height=image_size |
| ) |
| self.scene = pyrender.Scene(bg_color=bg_color, ambient_light=(0.05, 0.05, 0.05)) |
|
|
| self.camera = pyrender.IntrinsicsCamera( |
| fx=focal_length, fy=focal_length, cx=image_size / 2, cy=image_size / 2 |
| ) |
| self.camera_node = self.scene.add(self.camera, pose=np.eye(4)) |
|
|
| self.light = pyrender.DirectionalLight(color=[1.0, 1.0, 1.0], intensity=light_intensity) |
| self.light_node = self.scene.add(self.light, pose=np.eye(4)) |
|
|
| self.mesh_node = None |
| self._cached_faces = None |
| self._cached_material = None |
| self._cached_vertex_colors = None |
|
|
| def setup_mesh( |
| self, |
| faces: np.ndarray, |
| mesh_color: Tuple[float, float, float, float] = (0.69, 0.39, 0.96, 1.0), |
| cam_pose: Optional[np.ndarray] = None, |
| light_dir: Optional[np.ndarray] = None, |
| metallic: float = 0.0, |
| roughness: float = 0.5, |
| base_color_factor: Tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0), |
| ): |
| """Pre-compute scene state that stays constant across a frame sequence. |
| |
| Call once before a render_frame() loop. Camera pose, light pose, |
| face topology, material, and vertex colours are set here and reused |
| for every subsequent render_frame() call. |
| """ |
| if cam_pose is not None: |
| self.scene.set_pose(self.camera_node, pose=cam_pose) |
| if light_dir is not None: |
| light_pose = look_at(np.zeros(3), light_dir, np.array([0.0, 1.0, 0.0])) |
| self.scene.set_pose(self.light_node, pose=light_pose) |
| elif cam_pose is not None: |
| self.scene.set_pose(self.light_node, pose=cam_pose) |
|
|
| self._cached_faces = np.asarray(faces, dtype=np.int32) |
| if len(mesh_color) == 3: |
| mesh_color = (*mesh_color, 1.0) |
| self._cached_mesh_color = np.array(mesh_color, dtype=np.float32) |
| self._cached_material = self.pyrender.MetallicRoughnessMaterial( |
| metallicFactor=metallic, |
| roughnessFactor=roughness, |
| baseColorFactor=base_color_factor, |
| doubleSided=True, |
| ) |
| self._cached_vertex_colors = None |
|
|
| def render_frame(self, vertices: np.ndarray) -> np.ndarray: |
| """Fast render path — only vertex positions change. |
| |
| Reuses the faces / material / colours / camera set by setup_mesh(). |
| Builds a pyrender Primitive directly (skips trimesh object creation |
| and Mesh.from_trimesh overhead). |
| """ |
| verts = np.asarray(vertices, dtype=np.float32) |
|
|
| if self._cached_vertex_colors is None or self._cached_vertex_colors.shape[0] != len(verts): |
| self._cached_vertex_colors = np.tile(self._cached_mesh_color, (len(verts), 1)) |
|
|
| normals = _compute_vertex_normals(verts, self._cached_faces) |
|
|
| primitive = self.pyrender.Primitive( |
| positions=verts, |
| normals=normals, |
| indices=self._cached_faces, |
| color_0=self._cached_vertex_colors, |
| material=self._cached_material, |
| mode=4, |
| ) |
| mesh = self.pyrender.Mesh(primitives=[primitive]) |
|
|
| if self.mesh_node is not None: |
| self.scene.remove_node(self.mesh_node) |
| self.mesh_node = self.scene.add(mesh) |
|
|
| color, _ = self.renderer.render(self.scene) |
| return color |
|
|
| def render( |
| self, |
| vertices: np.ndarray, |
| faces: np.ndarray, |
| mesh_color: Tuple[float, float, float, float] = (0.69, 0.39, 0.96, 1.0), |
| cam_pose: Optional[np.ndarray] = None, |
| light_dir: Optional[np.ndarray] = None, |
| metallic: float = 0.0, |
| roughness: float = 0.5, |
| base_color_factor: Tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0), |
| ) -> np.ndarray: |
| import trimesh |
|
|
| |
| if cam_pose is None: |
| cam_pose = compute_camera_pose(vertices) |
| self.scene.set_pose(self.camera_node, pose=cam_pose) |
|
|
| |
| if light_dir is not None: |
| light_pose = look_at(np.zeros(3), light_dir, np.array([0.0, 1.0, 0.0])) |
| self.scene.set_pose(self.light_node, pose=light_pose) |
| else: |
| self.scene.set_pose(self.light_node, pose=cam_pose) |
|
|
| |
| if self.mesh_node is not None: |
| self.scene.remove_node(self.mesh_node) |
|
|
| if len(mesh_color) == 3: |
| mesh_color = (*mesh_color, 1.0) |
|
|
| |
| verts = np.asarray(vertices, dtype=np.float32) |
| faces = np.asarray(faces, dtype=np.int32) |
| vertex_colors = np.tile(mesh_color, (len(verts), 1)) |
|
|
| tmesh = trimesh.Trimesh(vertices=verts, faces=faces, process=False) |
| tmesh.visual.vertex_colors = vertex_colors |
|
|
| |
| mesh = self.pyrender.Mesh.from_trimesh(tmesh, smooth=True) |
|
|
| |
| material = self.pyrender.MetallicRoughnessMaterial( |
| metallicFactor=metallic, |
| roughnessFactor=roughness, |
| baseColorFactor=base_color_factor, |
| doubleSided=True, |
| ) |
| for prim in mesh.primitives: |
| prim.material = material |
|
|
| self.mesh_node = self.scene.add(mesh) |
|
|
| |
| color, _ = self.renderer.render(self.scene) |
| return color |
|
|
| def delete(self): |
| self.renderer.delete() |
|
|
|
|
| def render_mesh( |
| vertices: np.ndarray, |
| faces: np.ndarray, |
| image_size: int = 512, |
| bg_color: Tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0), |
| mesh_color: Tuple[float, float, float, float] = (0.69, 0.39, 0.96, 1.0), |
| focal_length: float = 4000.0, |
| light_intensity: float = 3.0, |
| cam_pose=None, |
| light_dir: Optional[np.ndarray] = None, |
| ) -> np.ndarray: |
| """ |
| Legacy function for backward compatibility. |
| WARNING: This is inefficient for loops as it creates/destroys the renderer every time. |
| Use MeshRenderer class instead. |
| """ |
| renderer = MeshRenderer( |
| image_size=image_size, |
| bg_color=bg_color, |
| focal_length=focal_length, |
| light_intensity=light_intensity, |
| ) |
| color = renderer.render( |
| vertices, faces, mesh_color=mesh_color, cam_pose=cam_pose, light_dir=light_dir |
| ) |
| renderer.delete() |
| return color |
|
|
|
|
| def render_comparison_video( |
| out_path: str, |
| verts_source: np.ndarray, |
| faces_source: np.ndarray, |
| verts_soma: np.ndarray, |
| faces_soma: np.ndarray, |
| *, |
| color_source: Tuple[float, float, float, float] = (0.98, 0.65, 0.15, 1.0), |
| color_soma: Tuple[float, float, float, float] = (0.55, 0.15, 0.85, 1.0), |
| cam_pose: Optional[np.ndarray] = None, |
| light_dir: Optional[np.ndarray] = None, |
| image_size: int = 1024, |
| fps: int = 30, |
| center: bool = False, |
| cam_dist_scale: float = 2.5, |
| label_source: str = "Source", |
| label_soma: str = "SOMA", |
| ) -> None: |
| """Render a blended comparison video of source vs SOMA meshes. |
| |
| Uses the fast setup_mesh() + render_frame() path with a streaming |
| video writer (no frame accumulation in memory). |
| |
| Args: |
| out_path: Output video file path (e.g. 'out/comparison.mp4'). |
| verts_source: (N, V_src, 3) source model vertices. |
| verts_soma: (N, V_soma, 3) SOMA reconstruction vertices. |
| faces_source: (F_src, 3) source face indices. |
| faces_soma: (F_soma, 3) SOMA face indices. |
| color_source: RGBA colour for source mesh. |
| color_soma: RGBA colour for SOMA mesh. |
| cam_pose: 4x4 camera pose. Auto-computed from first frame if None. |
| light_dir: Light direction vector. Defaults to (0, -0.5, -1). |
| image_size: Render resolution (square). |
| fps: Video frame rate. |
| center: Subtract per-frame centroid from both meshes so the |
| character stays centered. Useful for motions with large |
| global translations (e.g. SAM 3D Body). |
| label_source: Name for source mesh (for print message). |
| label_soma: Name for SOMA mesh (for print message). |
| """ |
| import imageio.v2 as imageio |
| from tqdm import tqdm |
|
|
| N = len(verts_source) |
| faces_source = np.asarray(faces_source, dtype=np.int32) |
| faces_soma = np.asarray(faces_soma, dtype=np.int32) |
|
|
| if center: |
| |
| centroids = verts_source.mean(axis=1, keepdims=True) |
| verts_source = verts_source - centroids |
| verts_soma = verts_soma - centroids |
|
|
| if cam_pose is None: |
| cam_pose = compute_camera_pose(verts_source[0], cam_dist_scale=cam_dist_scale) |
| if light_dir is None: |
| light_dir = np.array([0.0, -0.5, -1.0]) |
|
|
| render_kwargs = dict( |
| cam_pose=cam_pose, |
| light_dir=light_dir, |
| metallic=0.0, |
| roughness=0.5, |
| base_color_factor=[0.9, 0.9, 0.9, 1.0], |
| ) |
| renderer = MeshRenderer(image_size=image_size, light_intensity=5) |
|
|
| os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) |
| writer = imageio.get_writer(out_path, fps=fps) |
|
|
| for t in tqdm(range(N), desc="Rendering"): |
| renderer.setup_mesh(faces=faces_source, mesh_color=color_source, **render_kwargs) |
| img_source = renderer.render_frame(verts_source[t]) |
|
|
| renderer.setup_mesh(faces=faces_soma, mesh_color=color_soma, **render_kwargs) |
| img_soma = renderer.render_frame(verts_soma[t]) |
|
|
| img_combined = (0.5 * img_source + 0.5 * img_soma).astype(np.uint8) |
| writer.append_data(img_combined[..., ::-1]) |
|
|
| writer.close() |
| renderer.delete() |
| print(f"\nSaved: {out_path} ({label_source}=orange, {label_soma}=purple)") |
|
|
|
|
| def save_image(path: str, image: np.ndarray) -> None: |
| try: |
| import imageio.v2 as imageio |
| except Exception as exc: |
| raise RuntimeError( |
| "imageio is required to save images. Install with `pip install imageio`." |
| ) from exc |
|
|
| imageio.imwrite(path, image) |
|
|