# This script is borrowed from https://github.com/allenai/objaverse-rendering import argparse import math import os from pathlib import Path import shutil from typing import Dict, Literal, Tuple import bpy from mathutils import Vector from PIL import Image # --- Blender Setup Functions --- def global_settings(): """Configures global Blender rendering settings.""" context = bpy.context scene = context.scene render = scene.render render.engine = "CYCLES" render.image_settings.file_format = "PNG" render.image_settings.color_mode = "RGBA" render.resolution_x = 512 render.resolution_y = 512 render.resolution_percentage = 100 scene.cycles.device = "GPU" scene.cycles.samples = 32 scene.cycles.diffuse_bounces = 1 scene.cycles.glossy_bounces = 1 scene.cycles.transparent_max_bounces = 3 scene.cycles.transmission_bounces = 3 scene.cycles.filter_width = 0.01 scene.cycles.use_denoising = True scene.render.film_transparent = True return scene def add_lighting() -> None: """Adds area lights to the scene.""" # Delete the default light if "Light" in bpy.data.objects: bpy.data.objects["Light"].select_set(True) bpy.ops.object.delete() # Add a new large area light bpy.ops.object.light_add(type="AREA") light2 = bpy.data.lights["Area"] light2.energy = 30000 bpy.data.objects["Area"].location[2] = 0.5 bpy.data.objects["Area"].scale[0] = 100 bpy.data.objects["Area"].scale[1] = 100 bpy.data.objects["Area"].scale[2] = 100 # Add a fill light bpy.ops.object.light_add(type="AREA", location=(0, 0, 2)) fill_obj = bpy.context.active_object fill_obj.data.energy = 2000 fill_obj.scale = (10, 10, 10) def reset_scene() -> None: """Resets the scene to a clean state by deleting all objects and data.""" # Delete all objects bpy.ops.object.select_all(action='SELECT') bpy.ops.object.delete() # Delete all meshes for block in bpy.data.meshes: bpy.data.meshes.remove(block, do_unlink=True) # Delete all materials for material in bpy.data.materials: bpy.data.materials.remove(material, do_unlink=True) # Delete all textures for texture in bpy.data.textures: bpy.data.textures.remove(texture, do_unlink=True) # Delete all images for image in bpy.data.images: bpy.data.images.remove(image, do_unlink=True) # Delete all lights for light in bpy.data.lights: bpy.data.lights.remove(light, do_unlink=True) # Delete all cameras for cam in bpy.data.cameras: bpy.data.cameras.remove(cam, do_unlink=True) # Delete all empties and curves for curve in bpy.data.curves: bpy.data.curves.remove(curve, do_unlink=True) # Reset world if bpy.data.worlds: for world in bpy.data.worlds: bpy.data.worlds.remove(world, do_unlink=True) # Create a new default world bpy.context.scene.world = bpy.data.worlds.new("World") bpy.context.view_layer.update() def load_object(object_path: str) -> None: """Loads a 3D model into the scene based on its file extension.""" if object_path.endswith(".glb"): bpy.ops.import_scene.gltf(filepath=object_path, merge_vertices=True) elif object_path.endswith(".fbx"): bpy.ops.import_scene.fbx(filepath=object_path) else: raise ValueError(f"Unsupported file type: {object_path}") # --- Scene Normalization and Utility Functions --- def scene_bbox(single_obj=None, ignore_matrix=False): """Calculates the bounding box of the scene or a single object.""" bbox_min = (math.inf,) * 3 bbox_max = (-math.inf,) * 3 found = False for obj in scene_meshes() if single_obj is None else [single_obj]: found = True for coord in obj.bound_box: coord = Vector(coord) if not ignore_matrix: coord = obj.matrix_world @ coord bbox_min = tuple(min(x, y) for x, y in zip(bbox_min, coord)) bbox_max = tuple(max(x, y) for x, y in zip(bbox_max, coord)) if not found: raise RuntimeError("No objects in scene to compute bounding box for") return Vector(bbox_min), Vector(bbox_max) def scene_root_objects(): """Generator for all root objects in the scene.""" for obj in bpy.context.scene.objects.values(): if not obj.parent: yield obj def scene_meshes(): """Generator for all mesh objects in the scene.""" for obj in bpy.context.scene.objects.values(): if isinstance(obj.data, (bpy.types.Mesh)): yield obj def normalize_scene(target_scale=1.0): """Normalizes the scene: scales to fit target size and centers at the origin.""" bbox_min, bbox_max = scene_bbox() size = bbox_max - bbox_min max_dim = max(size.x, size.y, size.z) if max_dim == 0: raise ValueError("Model has zero size. Cannot normalize.") scale = target_scale / max_dim for obj in scene_root_objects(): obj.scale = obj.scale * scale bpy.context.view_layer.update() bbox_min, bbox_max = scene_bbox() center = (bbox_min + bbox_max) * 0.5 for obj in scene_root_objects(): obj.location -= center bpy.context.view_layer.update() # --- Camera and Lighting Setup --- def setup_camera(scene): """Configures the camera and adds a tracking constraint.""" cam = scene.objects["Camera"] cam.location = (0, 1.2, 0) cam.data.lens = 35 cam.data.sensor_width = 32 cam_constraint = cam.constraints.new(type="TRACK_TO") cam_constraint.track_axis = "TRACK_NEGATIVE_Z" cam_constraint.up_axis = "UP_Y" return cam, cam_constraint def _create_light( name: str, light_type: Literal["POINT", "SUN", "SPOT", "AREA"], location: Tuple[float, float, float], rotation: Tuple[float, float, float], energy: float, use_shadow: bool = False, specular_factor: float = 1.0, ) -> bpy.types.Object: """Creates and returns a configured light object.""" light_data = bpy.data.lights.new(name=name, type=light_type) light_object = bpy.data.objects.new(name, light_data) bpy.context.collection.objects.link(light_object) light_object.location = location light_object.rotation_euler = rotation light_data.energy = energy light_data.use_shadow = use_shadow light_data.specular_factor = specular_factor return light_object def create_lighting() -> Dict[str, bpy.types.Object]: """Creates a deterministic multi-directional sun lighting setup.""" # Remove existing lights bpy.ops.object.select_all(action="DESELECT") bpy.ops.object.select_by_type(type="LIGHT") bpy.ops.object.delete() # Add 4 deterministic sun lights key_light = _create_light( name="Key_Light", light_type="SUN", location=(0, 0, 0), rotation=(0.785398, 0, -0.785398), # 45°, -45° in radians energy=0.5, ) fill_light = _create_light( name="Fill_Light", light_type="SUN", location=(0, 0, 0), rotation=(0.785398, 0, 2.35619), # 45°, 135° energy=0.3, ) rim_light = _create_light( name="Rim_Light", light_type="SUN", location=(0, 0, 0), rotation=(-0.785398, 0, -3.92699), # -45°, -225° energy=0.5, ) bottom_light = _create_light( name="Bottom_Light", light_type="SUN", location=(0, 0, 0), rotation=(3.14159, 0, 0), # 180° (from below) energy=0.2, ) return { "key_light": key_light, "fill_light": fill_light, "rim_light": rim_light, "bottom_light": bottom_light, } # --- Main Rendering and Image Processing Functions --- def render_object( object_file: str, output_dir: str, camera_views=[(30, 30, 1.5), (90, -20, 1.5), (150, 30, 1.5), (210, -20, 1.5), (270, 30, 1.5), (330, -20, 1.5)], background_color=(255, 255, 255) ) -> None: """Renders images of an object from multiple camera views.""" scene = global_settings() os.makedirs(output_dir, exist_ok=True) reset_scene() # Create and set up a new camera bpy.ops.object.camera_add() camera = bpy.context.object camera.name = "Camera" scene.collection.objects.link(camera) scene.camera = camera scene.view_settings.view_transform = 'Standard' # Set background color world = bpy.data.worlds["World"] world.use_nodes = False world.color = tuple(channel / 255 for channel in background_color) scene.render.film_transparent = False scene.world = world # Load, normalize, and light the object load_object(object_file) normalize_scene() create_lighting() cam, cam_constraint = setup_camera(scene) # Create an empty object for the camera to track empty = bpy.data.objects.new("Empty", None) scene.collection.objects.link(empty) cam_constraint.target = empty for i, (azim, elev, camera_dist) in enumerate(camera_views): # Set camera position theta = math.radians(azim) phi = math.radians(elev) point = ( camera_dist * math.cos(phi) * math.cos(theta), camera_dist * math.cos(phi) * math.sin(theta), camera_dist * math.sin(phi), ) cam.location = point # Render the image render_path = os.path.join(output_dir, f"{i:02d}.png") scene.render.filepath = render_path bpy.ops.render.render(write_still=True) def create_tiled_grid( image_paths=["00.png", "01.png", "02.png", "03.png", "04.png", "05.png"], output_path="tiled_grid.png", tile_width=320, tile_height=320, background_color=(255, 255, 255), ): """Creates a 2x3 tiled grid image from a list of six image paths.""" if len(image_paths) != 6: print("Error: Exactly 6 image paths are required.") return grid_width = tile_width * 2 grid_height = tile_height * 3 grid_image = Image.new("RGB", (grid_width, grid_height), background_color) for i, image_path in enumerate(image_paths): img = Image.open(image_path) img = img.resize((tile_width, tile_height)) # Handle transparency by pasting onto a solid background if img.mode == "RGBA": background = Image.new("RGB", (tile_width, tile_height), background_color) background.paste(img, (0, 0), img) img = background x = (i % 2) * tile_width y = (i // 2) * tile_height grid_image.paste(img, (x, y)) grid_image.save(output_path) print(f"Tiled grid image saved to: {output_path}") # --- Main Execution Block --- if __name__ == "__main__": parser = argparse.ArgumentParser(description="Render a 3D object into a multi-view and source image format for EditP23.") parser.add_argument("--mesh_path", type=str, required=True, help="Path to the input .glb or .fbx file.") parser.add_argument("--output_dir", type=str, required=True, help="Directory to save the output src.png and src_mv.png.") parser.add_argument("--camera_dist", type=float, default=1.35, help="Camera distance from the object.") parser.add_argument("--azim_offset", type=float, default=0, help="Azimuthal offset for camera views in degrees.") args = parser.parse_args() RENDERS_SUBDIR = "all_renders" BACKGROUND_COLOR = (255, 255, 255) output_dir = Path(args.output_dir) renders_path = output_dir / RENDERS_SUBDIR ELEV_1 = 20 ELEV_2 = -10 elevs = [ELEV_1, ELEV_2] * 3 azims = [(30 + 60 * i + args.azim_offset) % 360 for i in range(6)] camera_views = [(azim, elev, args.camera_dist) for azim, elev in zip(azims, elevs)] + [ ((0 + args.azim_offset) % 360, ELEV_1, args.camera_dist) ] # Render the object from different views render_object( args.mesh_path, output_dir=str(renders_path), camera_views=camera_views, background_color=BACKGROUND_COLOR, ) # --- Create Final Outputs --- image_paths_for_grid = [renders_path / f"{i:02d}.png" for i in range(6)] create_tiled_grid( image_paths=image_paths_for_grid, output_path=str(output_dir/"src_mv.png"), background_color=BACKGROUND_COLOR, ) shutil.copy(renders_path / "06.png", output_dir / "src.png") print(f"Saved conditioning view and multi-view grid to {renders_path}.")