236 lines
6.6 KiB
Python
236 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import argparse
|
|
import os
|
|
import platform
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# On a headless Linux box pyrender needs an offscreen GL backend (EGL); without
|
|
# this the OffscreenRenderer cannot create a context. Must run before importing
|
|
# pyrender. A desktop session (DISPLAY set) is left untouched.
|
|
if platform.system() == "Linux" and not os.environ.get("DISPLAY"):
|
|
os.environ.setdefault("PYOPENGL_PLATFORM", "egl")
|
|
|
|
import numpy as np
|
|
import trimesh
|
|
import pyrender
|
|
from PIL import Image
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from draco_glb import load_glb_scene
|
|
|
|
|
|
def rotation_matrix_x(angle_rad):
|
|
c = np.cos(angle_rad)
|
|
s = np.sin(angle_rad)
|
|
|
|
return np.array([
|
|
[1, 0, 0, 0],
|
|
[0, c, -s, 0],
|
|
[0, s, c, 0],
|
|
[0, 0, 0, 1]
|
|
])
|
|
|
|
|
|
def look_at(camera_position, target, up=np.array([0.0, 0.0, 1.0])):
|
|
forward = target - camera_position
|
|
f_norm = np.linalg.norm(forward)
|
|
|
|
# Avoid zero-length forward vectors
|
|
if f_norm < 1e-6:
|
|
forward = np.array([0.0, -1.0, 0.0])
|
|
else:
|
|
forward = forward / f_norm
|
|
|
|
right = np.cross(forward, up)
|
|
r_norm = np.linalg.norm(right)
|
|
|
|
# Handle the case where forward points straight up/down
|
|
if r_norm < 1e-6:
|
|
alternative_up = np.array([1.0, 0.0, 0.0])
|
|
right = np.cross(forward, alternative_up)
|
|
right = right / max(np.linalg.norm(right), 1e-6)
|
|
else:
|
|
right = right / r_norm
|
|
|
|
true_up = np.cross(right, forward)
|
|
true_up = true_up / max(np.linalg.norm(true_up), 1e-6)
|
|
|
|
pose = np.eye(4)
|
|
pose[:3, 0] = right
|
|
pose[:3, 1] = true_up
|
|
pose[:3, 2] = -forward
|
|
pose[:3, 3] = camera_position
|
|
|
|
return pose
|
|
|
|
|
|
def compute_bounds(scene):
|
|
mins = np.array([np.inf, np.inf, np.inf])
|
|
maxs = np.array([-np.inf, -np.inf, -np.inf])
|
|
|
|
for geom in scene.geometry.values():
|
|
if len(geom.vertices) == 0:
|
|
continue
|
|
verts = np.asarray(geom.vertices)
|
|
if not np.isfinite(verts).all():
|
|
continue
|
|
mins = np.minimum(mins, verts.min(axis=0))
|
|
maxs = np.maximum(maxs, verts.max(axis=0))
|
|
|
|
# Fallback if no valid geometry was found
|
|
if np.any(np.isinf(mins)) or np.any(np.isinf(maxs)):
|
|
return np.zeros(3), np.ones(3), 1.0
|
|
|
|
center = (mins + maxs) * 0.5
|
|
extents = maxs - mins
|
|
|
|
# Enforce a non-zero norm baseline
|
|
extents_norm = np.linalg.norm(extents)
|
|
radius = max(extents_norm * 0.5, 0.001)
|
|
|
|
return center, extents, radius
|
|
|
|
|
|
def trimesh_to_pyrender(scene):
|
|
render_scene = pyrender.Scene(
|
|
bg_color=[255, 255, 255, 255],
|
|
ambient_light=[0.25, 0.25, 0.25]
|
|
)
|
|
|
|
for name, geom in scene.geometry.items():
|
|
if len(geom.vertices) == 0:
|
|
print(f"Skipping empty mesh: {name}")
|
|
continue
|
|
|
|
verts = np.asarray(geom.vertices)
|
|
if not np.isfinite(verts).all():
|
|
print(f"Skipping invalid mesh: {name}")
|
|
continue
|
|
|
|
try:
|
|
# Explicitly force trimesh to generate flat normals ahead of time
|
|
# This ensures pyrender never triggers its stateful eigenvalue calculation
|
|
geom.fix_normals()
|
|
|
|
# Construct manual pyrender material mapping to bypass trimesh parsing
|
|
material = pyrender.MetallicRoughnessMaterial(
|
|
doubleSided=True,
|
|
baseColorFactor=[0.7, 0.7, 0.7, 1.0],
|
|
metallicFactor=0.2,
|
|
roughnessFactor=0.8
|
|
)
|
|
|
|
# Build primitive tracking geometry manually
|
|
primitive = pyrender.Primitive(
|
|
positions=verts,
|
|
indices=np.asarray(geom.faces, dtype=np.uint32),
|
|
normals=np.asarray(geom.vertex_normals),
|
|
material=material,
|
|
mode=pyrender.GLTF.TRIANGLES
|
|
)
|
|
|
|
mesh = pyrender.Mesh(primitives=[primitive], name=name)
|
|
render_scene.add(mesh)
|
|
|
|
except Exception as e:
|
|
# Fallback to direct raw primitives if the normal engine still complains
|
|
try:
|
|
primitive = pyrender.Primitive(
|
|
positions=verts,
|
|
indices=np.asarray(geom.faces, dtype=np.uint32),
|
|
material=material,
|
|
mode=pyrender.GLTF.TRIANGLES
|
|
)
|
|
mesh = pyrender.Mesh(primitives=[primitive], name=name)
|
|
render_scene.add(mesh)
|
|
except Exception as inner_e:
|
|
print(f"❌ Failed to render mesh {name}: {inner_e}")
|
|
|
|
return render_scene
|
|
|
|
|
|
def add_raymond_lights(scene):
|
|
light_positions = [
|
|
[5, 5, 10],
|
|
[-5, 5, 10],
|
|
[0, -5, 10]
|
|
]
|
|
|
|
for pos in light_positions:
|
|
light = pyrender.DirectionalLight(
|
|
color=np.ones(3),
|
|
intensity=3.0
|
|
)
|
|
|
|
pose = np.eye(4)
|
|
pose[:3, 3] = pos
|
|
|
|
scene.add(light, pose=pose)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--model", required=True)
|
|
parser.add_argument("--output", default="render.png")
|
|
parser.add_argument("--width", type=int, default=1024)
|
|
parser.add_argument("--height", type=int, default=1024)
|
|
|
|
args = parser.parse_args()
|
|
|
|
model_path = Path(args.model)
|
|
|
|
if not model_path.exists():
|
|
raise FileNotFoundError(model_path)
|
|
|
|
trimesh_scene = load_glb_scene(model_path)
|
|
|
|
center, extents, radius = compute_bounds(trimesh_scene)
|
|
|
|
scene = trimesh_to_pyrender(trimesh_scene)
|
|
|
|
add_raymond_lights(scene)
|
|
|
|
fov_y = np.radians(45.0)
|
|
|
|
desired_fill = 0.75
|
|
|
|
distance = radius / np.tan((fov_y * desired_fill) / 2.0)
|
|
|
|
angle_deg = 30.0
|
|
angle_rad = np.radians(angle_deg)
|
|
|
|
horizontal_distance = distance * np.cos(angle_rad)
|
|
vertical_offset = distance * np.sin(angle_rad)
|
|
|
|
camera_position = center + np.array([
|
|
horizontal_distance,
|
|
-horizontal_distance,
|
|
vertical_offset
|
|
])
|
|
|
|
camera_pose = look_at(camera_position, center)
|
|
|
|
camera = pyrender.PerspectiveCamera(
|
|
yfov=fov_y
|
|
)
|
|
|
|
scene.add(camera, pose=camera_pose)
|
|
|
|
renderer = pyrender.OffscreenRenderer(
|
|
viewport_width=args.width,
|
|
viewport_height=args.height
|
|
)
|
|
|
|
color, depth = renderer.render(scene)
|
|
|
|
Image.fromarray(color).save(args.output)
|
|
|
|
renderer.delete()
|
|
|
|
print(f"Saved: {args.output}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |