Spaces:
Runtime error
Runtime error
| """Blender script to render images of 3D models. | |
| This script is used to render images of 3D models. It takes in a list of paths | |
| to .glb files and renders images of each model. The images are from rotating the | |
| object around the origin. The images are saved to the output directory. | |
| Example usage: | |
| blender -b -P blender_script.py -- \ | |
| --object_path my_object.glb \ | |
| --output_dir ./views \ | |
| --engine CYCLES \ | |
| --scale 0.8 \ | |
| --num_images 12 \ | |
| --camera_dist 1.2 | |
| Here, input_model_paths.json is a json file containing a list of paths to .glb. | |
| """ | |
| import argparse | |
| import json | |
| import math | |
| import os | |
| import random | |
| import sys | |
| import time | |
| import urllib.request | |
| import uuid | |
| from typing import Tuple | |
| from mathutils import Vector, Matrix | |
| import numpy as np | |
| import bpy | |
| from mathutils import Vector | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument( | |
| "--object_path", | |
| type=str, | |
| required=True, | |
| help="Path to the object file", | |
| ) | |
| parser.add_argument("--output_dir", type=str, default="/home/") | |
| parser.add_argument( | |
| "--engine", type=str, default="CYCLES", choices=["CYCLES", "BLENDER_EEVEE"] | |
| ) | |
| parser.add_argument("--scale", type=float, default=0.8) | |
| parser.add_argument("--num_images", type=int, default=8) | |
| parser.add_argument("--camera_dist", type=float, default=1.2) | |
| argv = sys.argv[sys.argv.index("--") + 1 :] | |
| args = parser.parse_args(argv) | |
| print('===================', args.engine, '===================') | |
| context = bpy.context | |
| scene = context.scene | |
| render = scene.render | |
| 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" | |
| # setup lighting | |
| bpy.ops.object.light_add(type="AREA") | |
| light2 = bpy.data.lights["Area"] | |
| light2.energy = 3000 | |
| 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 | |
| render.engine = args.engine | |
| 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 = 128 | |
| 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 | |
| bpy.context.preferences.addons["cycles"].preferences.get_devices() | |
| # Set the device_type | |
| bpy.context.preferences.addons[ | |
| "cycles" | |
| ].preferences.compute_device_type = "CUDA" # or "OPENCL" | |
| def sample_point_on_sphere(radius: float) -> Tuple[float, float, float]: | |
| theta = random.random() * 2 * math.pi | |
| phi = math.acos(2 * random.random() - 1) | |
| return ( | |
| radius * math.sin(phi) * math.cos(theta), | |
| radius * math.sin(phi) * math.sin(theta), | |
| radius * math.cos(phi), | |
| ) | |
| def sample_spherical(radius=3.0, maxz=3.0, minz=0.): | |
| correct = False | |
| while not correct: | |
| vec = np.random.uniform(-1, 1, 3) | |
| vec[2] = np.abs(vec[2]) | |
| vec = vec / np.linalg.norm(vec, axis=0) * radius | |
| if maxz > vec[2] > minz: | |
| correct = True | |
| return vec | |
| def sample_spherical(radius_min=1.5, radius_max=2.0, maxz=1.6, minz=-0.75): | |
| correct = False | |
| while not correct: | |
| vec = np.random.uniform(-1, 1, 3) | |
| # vec[2] = np.abs(vec[2]) | |
| radius = np.random.uniform(radius_min, radius_max, 1) | |
| vec = vec / np.linalg.norm(vec, axis=0) * radius[0] | |
| if maxz > vec[2] > minz: | |
| correct = True | |
| return vec | |
| def randomize_camera(): | |
| elevation = random.uniform(0., 90.) | |
| azimuth = random.uniform(0., 360) | |
| distance = random.uniform(0.8, 1.6) | |
| return set_camera_location(elevation, azimuth, distance) | |
| def set_camera_location(elevation, azimuth, distance): | |
| # from https://blender.stackexchange.com/questions/18530/ | |
| # x, y, z = sample_spherical(radius_min=1.5, radius_max=2.2, maxz=2.2, minz=-2.2) # FIXME: too Far? # four for this | |
| x, y, z = sample_spherical(radius_min=1.7, radius_max=2.0, maxz=1.1, minz=0.9) # one for this | |
| # x, y, z = sample_spherical(radius_min=1.2, radius_max=2.0, maxz=2.2, minz=-2.2) | |
| # x, y, z = 0, -1.2, 0 | |
| camera = bpy.data.objects["Camera"] | |
| camera.location = x, y, z | |
| direction = - camera.location | |
| rot_quat = direction.to_track_quat('-Z', 'Y') | |
| camera.rotation_euler = rot_quat.to_euler() | |
| return camera | |
| def set_camera_location_old(elevation, azimuth, distance): | |
| # from https://blender.stackexchange.com/questions/18530/ | |
| x, y, z = sample_spherical(radius_min=1.5, radius_max=2.2, maxz=2.2, minz=-2.2) # FIXME: too Far? # four for this | |
| # x, y, z = sample_spherical(radius_min=1.2, radius_max=2.0, maxz=2.2, minz=-2.2) | |
| # x, y, z = 0, -1.2, 0 | |
| camera = bpy.data.objects["Camera"] | |
| camera.location = x, y, z | |
| direction = - camera.location | |
| rot_quat = direction.to_track_quat('-Z', 'Y') | |
| camera.rotation_euler = rot_quat.to_euler() | |
| return camera | |
| def randomize_lighting() -> None: | |
| reset_lighting() | |
| # light2.energy = random.uniform(300, 600) | |
| # bpy.data.objects["Area"].location[0] = random.uniform(-1., 1.) | |
| # bpy.data.objects["Area"].location[1] = random.uniform(-1., 1.) | |
| # bpy.data.objects["Area"].location[2] = random.uniform(0.5, 1.5) | |
| def reset_lighting() -> None: | |
| light2.energy = 800 | |
| bpy.data.objects["Area"].location[0] = 0 | |
| bpy.data.objects["Area"].location[1] = 0 | |
| bpy.data.objects["Area"].location[2] = 1 | |
| def reset_scene() -> None: | |
| """Resets the scene to a clean state.""" | |
| # delete everything that isn't part of a camera or a light | |
| for obj in bpy.data.objects: | |
| if obj.type not in {"CAMERA", "LIGHT"}: | |
| bpy.data.objects.remove(obj, do_unlink=True) | |
| # delete all the materials | |
| for material in bpy.data.materials: | |
| bpy.data.materials.remove(material, do_unlink=True) | |
| # delete all the textures | |
| for texture in bpy.data.textures: | |
| bpy.data.textures.remove(texture, do_unlink=True) | |
| # delete all the images | |
| for image in bpy.data.images: | |
| bpy.data.images.remove(image, do_unlink=True) | |
| # load the glb model | |
| def load_object(object_path: str) -> None: | |
| """Loads a glb model into the scene.""" | |
| 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) | |
| elif object_path.endswith(".obj"): | |
| bpy.ops.import_scene.obj(filepath=object_path, axis_forward="Y", axis_up="Z") | |
| else: | |
| raise ValueError(f"Unsupported file type: {object_path}") | |
| def scene_bbox(single_obj=None, ignore_matrix=False): | |
| 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(): | |
| for obj in bpy.context.scene.objects.values(): | |
| if not obj.parent: | |
| yield obj | |
| def scene_meshes(): | |
| for obj in bpy.context.scene.objects.values(): | |
| if isinstance(obj.data, (bpy.types.Mesh)): | |
| yield obj | |
| # function from https://github.com/panmari/stanford-shapenet-renderer/blob/master/render_blender.py | |
| def get_3x4_RT_matrix_from_blender(cam): | |
| # bcam stands for blender camera | |
| # R_bcam2cv = Matrix( | |
| # ((1, 0, 0), | |
| # (0, 1, 0), | |
| # (0, 0, 1))) | |
| # Transpose since the rotation is object rotation, | |
| # and we want coordinate rotation | |
| # R_world2bcam = cam.rotation_euler.to_matrix().transposed() | |
| # T_world2bcam = -1*R_world2bcam @ location | |
| # | |
| # Use matrix_world instead to account for all constraints | |
| location, rotation = cam.matrix_world.decompose()[0:2] | |
| R_world2bcam = rotation.to_matrix().transposed() | |
| # Convert camera location to translation vector used in coordinate changes | |
| # T_world2bcam = -1*R_world2bcam @ cam.location | |
| # Use location from matrix_world to account for constraints: | |
| T_world2bcam = -1*R_world2bcam @ location | |
| # # Build the coordinate transform matrix from world to computer vision camera | |
| # R_world2cv = R_bcam2cv@R_world2bcam | |
| # T_world2cv = R_bcam2cv@T_world2bcam | |
| # put into 3x4 matrix | |
| RT = Matrix(( | |
| R_world2bcam[0][:] + (T_world2bcam[0],), | |
| R_world2bcam[1][:] + (T_world2bcam[1],), | |
| R_world2bcam[2][:] + (T_world2bcam[2],) | |
| )) | |
| return RT | |
| def normalize_scene(): | |
| bbox_min, bbox_max = scene_bbox() | |
| scale = 1 / max(bbox_max - bbox_min) | |
| for obj in scene_root_objects(): | |
| obj.scale = obj.scale * scale | |
| # Apply scale to matrix_world. | |
| bpy.context.view_layer.update() | |
| bbox_min, bbox_max = scene_bbox() | |
| offset = -(bbox_min + bbox_max) / 2 | |
| for obj in scene_root_objects(): | |
| obj.matrix_world.translation += offset | |
| bpy.ops.object.select_all(action="DESELECT") | |
| def save_images(object_file: str) -> None: | |
| """Saves rendered images of the object in the scene.""" | |
| os.makedirs(args.output_dir, exist_ok=True) | |
| reset_scene() | |
| # load the object | |
| load_object(object_file) | |
| object_uid = os.path.basename(object_file).split(".")[0] | |
| normalize_scene() | |
| # export mesh | |
| mesh_save_path = os.path.join(args.output_dir, object_uid + "_norm.obj") | |
| bpy.ops.export_scene.obj(filepath=mesh_save_path) | |
| # create an empty object to track | |
| empty = bpy.data.objects.new("Empty", None) | |
| scene.collection.objects.link(empty) | |
| cam_constraint.target = empty | |
| randomize_lighting() | |
| for i in range(args.num_images): | |
| # # set the camera position | |
| # theta = (i / args.num_images) * math.pi * 2 | |
| # phi = math.radians(60) | |
| # point = ( | |
| # args.camera_dist * math.sin(phi) * math.cos(theta), | |
| # args.camera_dist * math.sin(phi) * math.sin(theta), | |
| # args.camera_dist * math.cos(phi), | |
| # ) | |
| # # reset_lighting() | |
| # cam.location = point | |
| # set camera | |
| if i == 0: | |
| camera = set_camera_location(None, None, None) | |
| else: | |
| camera = set_camera_location_old(None, None, None) | |
| # render the image | |
| render_path = os.path.join(args.output_dir, object_uid, f"{i:03d}.png") | |
| scene.render.filepath = render_path | |
| bpy.ops.render.render(write_still=True) | |
| # save camera RT matrix | |
| RT = get_3x4_RT_matrix_from_blender(camera) | |
| print(RT) | |
| RT_path = os.path.join(args.output_dir, object_uid, f"{i:03d}.npy") | |
| np.save(RT_path, RT) | |
| # RT_path = os.path.join(args.output_dir, object_uid, f"{i:03d}.npy") | |
| # mat = camera.matrix_world | |
| # np.save(RT_path, mat) | |
| # print(mat) | |
| def download_object(object_url: str) -> str: | |
| """Download the object and return the path.""" | |
| # uid = uuid.uuid4() | |
| uid = object_url.split("/")[-1].split(".")[0] | |
| tmp_local_path = os.path.join("tmp-objects", f"{uid}.glb" + ".tmp") | |
| local_path = os.path.join("tmp-objects", f"{uid}.glb") | |
| # wget the file and put it in local_path | |
| os.makedirs(os.path.dirname(tmp_local_path), exist_ok=True) | |
| urllib.request.urlretrieve(object_url, tmp_local_path) | |
| os.rename(tmp_local_path, local_path) | |
| # get the absolute path | |
| local_path = os.path.abspath(local_path) | |
| return local_path | |
| if __name__ == "__main__": | |
| try: | |
| start_i = time.time() | |
| if args.object_path.startswith("http"): | |
| local_path = download_object(args.object_path) | |
| else: | |
| local_path = args.object_path | |
| save_images(local_path) | |
| end_i = time.time() | |
| print("Finished", local_path, "in", end_i - start_i, "seconds") | |
| # delete the object if it was downloaded | |
| if args.object_path.startswith("http"): | |
| os.remove(local_path) | |
| except Exception as e: | |
| print("Failed to render", args.object_path) | |
| print(e) | |