"""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)