3dai-backend / create_thumbnail.py
maxjski's picture
new model
07c2969
import argparse, io, math, os, sys
from pathlib import Path
from typing import Tuple
import numpy as np, requests
from PIL import Image
import trimesh, pyrender
from trimesh.transformations import translation_matrix, rotation_matrix # NEW
os.environ.setdefault("PYOPENGL_PLATFORM", "egl")
def download_to_memory(url: str) -> bytes:
resp = requests.get(url, timeout=30)
resp.raise_for_status()
return resp.content
def load_mesh(data: bytes) -> trimesh.Trimesh:
return trimesh.load(io.BytesIO(data), file_type="glb", force="mesh")
def build_scene(mesh: trimesh.Trimesh) -> Tuple[pyrender.Scene, float]:
tm_mesh = pyrender.Mesh.from_trimesh(mesh, smooth=False)
scene = pyrender.Scene(bg_color=[1, 1, 1, 0])
scene.add(tm_mesh)
bb = mesh.bounding_box_oriented.extents # fixed name
if not np.all(bb):
bb = mesh.extents
radius = np.linalg.norm(bb) * 0.6
return scene, radius
def add_lighting(scene: pyrender.Scene, radius: float) -> None:
key = pyrender.PointLight(color=np.ones(3), intensity=40.0)
fill = pyrender.PointLight(color=np.ones(3), intensity=20.0)
back = pyrender.PointLight(color=np.ones(3), intensity=10.0)
scene.add(key, pose=translation_matrix([ radius, radius, radius]))
scene.add(fill, pose=translation_matrix([-radius, radius, radius]))
scene.add(back, pose=translation_matrix([ 0, -radius, -radius]))
def setup_camera(scene: pyrender.Scene, radius: float) -> None:
cam = pyrender.PerspectiveCamera(yfov=np.radians(45.0))
cam_pose = translation_matrix([0, 0, radius * 2.5])
scene.add(cam, pose=cam_pose)
def render_thumbnail(scene: pyrender.Scene, size: int) -> Image.Image:
r = pyrender.OffscreenRenderer(viewport_width=size, viewport_height=size)
try:
color, _ = r.render(scene)
finally:
r.delete()
return Image.fromarray(color)
def generate_thumbnail(url: str, out_path: Path, size: int = 512) -> None:
raw = download_to_memory(url)
mesh = load_mesh(raw)
# Get mesh dimensions before any transformations
original_extents = mesh.extents
longest_dimension = np.max(original_extents)
# Print dimension info
print(f"Mesh dimensions (X, Y, Z): {original_extents}")
print(f"Longest dimension: {longest_dimension:.4f}")
# Scaling constant - you can use this to normalize models to a target size
target_size = 2.5 # Target longest dimension
scale_factor = target_size / longest_dimension
print(f"Scale factor to normalize to {target_size}: {scale_factor:.4f}")
# Calculate radius BEFORE scaling for consistent camera/lighting positioning
bb = mesh.bounding_box_oriented.extents
if not np.all(bb):
bb = mesh.extents
fixed_radius = np.linalg.norm(bb) * 0.6
mesh.apply_translation(-mesh.bounding_box.centroid)
# Apply the scaling transformation
mesh.apply_scale(scale_factor)
# Rotate 45 degrees to the left (around Y-axis)
rotation = rotation_matrix(np.radians(30), [0.3, -0.5, 0])
mesh.apply_transform(rotation)
# Build scene but use fixed radius for camera/lighting
tm_mesh = pyrender.Mesh.from_trimesh(mesh, smooth=False)
scene = pyrender.Scene(bg_color=[0.15, 0.15, 0.15, 1]) # Gray background
scene.add(tm_mesh)
add_lighting(scene, fixed_radius)
setup_camera(scene, fixed_radius)
img = render_thumbnail(scene, size)
img.save(out_path, "PNG")
print(f"Saved thumbnail → {out_path.resolve()}")
def parse_args(argv):
p = argparse.ArgumentParser(description="Render a GLB file to a PNG thumbnail")
p.add_argument("url"), p.add_argument("output")
p.add_argument("size", nargs="?", type=int, default=512)
return p.parse_args(argv)
if __name__ == "__main__":
args = parse_args(sys.argv[1:])
generate_thumbnail(args.url, Path(args.output), args.size)