| import argparse, sys, os, math, re, glob
|
| from typing import *
|
| import bpy
|
| from mathutils import Vector, Matrix
|
| import numpy as np
|
| import json
|
| import glob
|
|
|
|
|
| """=============== BLENDER ==============="""
|
|
|
| IMPORT_FUNCTIONS: Dict[str, Callable] = {
|
| "obj": bpy.ops.import_scene.obj,
|
| "glb": bpy.ops.import_scene.gltf,
|
| "gltf": bpy.ops.import_scene.gltf,
|
| "usd": bpy.ops.import_scene.usd,
|
| "fbx": bpy.ops.import_scene.fbx,
|
| "stl": bpy.ops.import_mesh.stl,
|
| "usda": bpy.ops.import_scene.usda,
|
| "dae": bpy.ops.wm.collada_import,
|
| "ply": bpy.ops.import_mesh.ply,
|
| "abc": bpy.ops.wm.alembic_import,
|
| "blend": bpy.ops.wm.append,
|
| }
|
|
|
| EXT = {
|
| 'PNG': 'png',
|
| 'JPEG': 'jpg',
|
| 'OPEN_EXR': 'exr',
|
| 'TIFF': 'tiff',
|
| 'BMP': 'bmp',
|
| 'HDR': 'hdr',
|
| 'TARGA': 'tga'
|
| }
|
|
|
| def init_render(engine='CYCLES', resolution=512, geo_mode=False):
|
| bpy.context.scene.render.engine = engine
|
| bpy.context.scene.render.resolution_x = resolution
|
| bpy.context.scene.render.resolution_y = resolution
|
| bpy.context.scene.render.resolution_percentage = 100
|
| bpy.context.scene.render.image_settings.file_format = 'PNG'
|
| bpy.context.scene.render.image_settings.color_mode = 'RGBA'
|
| bpy.context.scene.render.film_transparent = True
|
|
|
| bpy.context.scene.cycles.device = 'GPU'
|
| bpy.context.scene.cycles.samples = 128 if not geo_mode else 1
|
| bpy.context.scene.cycles.filter_type = 'BOX'
|
| bpy.context.scene.cycles.filter_width = 1
|
| bpy.context.scene.cycles.diffuse_bounces = 1
|
| bpy.context.scene.cycles.glossy_bounces = 1
|
| bpy.context.scene.cycles.transparent_max_bounces = 3 if not geo_mode else 0
|
| bpy.context.scene.cycles.transmission_bounces = 3 if not geo_mode else 1
|
| bpy.context.scene.cycles.use_denoising = True
|
|
|
| bpy.context.preferences.addons['cycles'].preferences.get_devices()
|
| bpy.context.preferences.addons['cycles'].preferences.compute_device_type = 'CUDA'
|
|
|
| def init_nodes(save_depth=False, save_normal=False, save_albedo=False, save_mist=False):
|
| if not any([save_depth, save_normal, save_albedo, save_mist]):
|
| return {}, {}
|
| outputs = {}
|
| spec_nodes = {}
|
|
|
| bpy.context.scene.use_nodes = True
|
| bpy.context.scene.view_layers['View Layer'].use_pass_z = save_depth
|
| bpy.context.scene.view_layers['View Layer'].use_pass_normal = save_normal
|
| bpy.context.scene.view_layers['View Layer'].use_pass_diffuse_color = save_albedo
|
| bpy.context.scene.view_layers['View Layer'].use_pass_mist = save_mist
|
|
|
| nodes = bpy.context.scene.node_tree.nodes
|
| links = bpy.context.scene.node_tree.links
|
| for n in nodes:
|
| nodes.remove(n)
|
|
|
| render_layers = nodes.new('CompositorNodeRLayers')
|
|
|
| if save_depth:
|
| depth_file_output = nodes.new('CompositorNodeOutputFile')
|
| depth_file_output.base_path = ''
|
| depth_file_output.file_slots[0].use_node_format = True
|
| depth_file_output.format.file_format = 'PNG'
|
| depth_file_output.format.color_depth = '16'
|
| depth_file_output.format.color_mode = 'BW'
|
|
|
| map = nodes.new(type="CompositorNodeMapRange")
|
| map.inputs[1].default_value = 0
|
| map.inputs[2].default_value = 10
|
| map.inputs[3].default_value = 0
|
| map.inputs[4].default_value = 1
|
|
|
| links.new(render_layers.outputs['Depth'], map.inputs[0])
|
| links.new(map.outputs[0], depth_file_output.inputs[0])
|
|
|
| outputs['depth'] = depth_file_output
|
| spec_nodes['depth_map'] = map
|
|
|
| if save_normal:
|
| normal_file_output = nodes.new('CompositorNodeOutputFile')
|
| normal_file_output.base_path = ''
|
| normal_file_output.file_slots[0].use_node_format = True
|
| normal_file_output.format.file_format = 'OPEN_EXR'
|
| normal_file_output.format.color_mode = 'RGB'
|
| normal_file_output.format.color_depth = '16'
|
|
|
| links.new(render_layers.outputs['Normal'], normal_file_output.inputs[0])
|
|
|
| outputs['normal'] = normal_file_output
|
|
|
| if save_albedo:
|
| albedo_file_output = nodes.new('CompositorNodeOutputFile')
|
| albedo_file_output.base_path = ''
|
| albedo_file_output.file_slots[0].use_node_format = True
|
| albedo_file_output.format.file_format = 'PNG'
|
| albedo_file_output.format.color_mode = 'RGBA'
|
| albedo_file_output.format.color_depth = '8'
|
|
|
| alpha_albedo = nodes.new('CompositorNodeSetAlpha')
|
|
|
| links.new(render_layers.outputs['DiffCol'], alpha_albedo.inputs['Image'])
|
| links.new(render_layers.outputs['Alpha'], alpha_albedo.inputs['Alpha'])
|
| links.new(alpha_albedo.outputs['Image'], albedo_file_output.inputs[0])
|
|
|
| outputs['albedo'] = albedo_file_output
|
|
|
| if save_mist:
|
| bpy.data.worlds['World'].mist_settings.start = 0
|
| bpy.data.worlds['World'].mist_settings.depth = 10
|
|
|
| mist_file_output = nodes.new('CompositorNodeOutputFile')
|
| mist_file_output.base_path = ''
|
| mist_file_output.file_slots[0].use_node_format = True
|
| mist_file_output.format.file_format = 'PNG'
|
| mist_file_output.format.color_mode = 'BW'
|
| mist_file_output.format.color_depth = '16'
|
|
|
| links.new(render_layers.outputs['Mist'], mist_file_output.inputs[0])
|
|
|
| outputs['mist'] = mist_file_output
|
|
|
| return outputs, spec_nodes
|
|
|
| def init_scene() -> None:
|
| """Resets the scene to a clean state.
|
|
|
| Returns:
|
| None
|
| """
|
|
|
| for obj in bpy.data.objects:
|
| bpy.data.objects.remove(obj, do_unlink=True)
|
|
|
|
|
| for material in bpy.data.materials:
|
| bpy.data.materials.remove(material, do_unlink=True)
|
|
|
|
|
| for texture in bpy.data.textures:
|
| bpy.data.textures.remove(texture, do_unlink=True)
|
|
|
|
|
| for image in bpy.data.images:
|
| bpy.data.images.remove(image, do_unlink=True)
|
|
|
| def init_camera():
|
| cam = bpy.data.objects.new('Camera', bpy.data.cameras.new('Camera'))
|
| bpy.context.collection.objects.link(cam)
|
| bpy.context.scene.camera = cam
|
| cam.data.sensor_height = 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'
|
| cam_empty = bpy.data.objects.new("Empty", None)
|
| cam_empty.location = (0, 0, 0)
|
| bpy.context.scene.collection.objects.link(cam_empty)
|
| cam_constraint.target = cam_empty
|
| return cam
|
|
|
| def init_lighting():
|
|
|
| bpy.ops.object.select_all(action="DESELECT")
|
| bpy.ops.object.select_by_type(type="LIGHT")
|
| bpy.ops.object.delete()
|
|
|
|
|
| default_light = bpy.data.objects.new("Default_Light", bpy.data.lights.new("Default_Light", type="POINT"))
|
| bpy.context.collection.objects.link(default_light)
|
| default_light.data.energy = 1000
|
| default_light.location = (4, 1, 6)
|
| default_light.rotation_euler = (0, 0, 0)
|
|
|
|
|
| top_light = bpy.data.objects.new("Top_Light", bpy.data.lights.new("Top_Light", type="AREA"))
|
| bpy.context.collection.objects.link(top_light)
|
| top_light.data.energy = 10000
|
| top_light.location = (0, 0, 10)
|
| top_light.scale = (100, 100, 100)
|
|
|
|
|
| bottom_light = bpy.data.objects.new("Bottom_Light", bpy.data.lights.new("Bottom_Light", type="AREA"))
|
| bpy.context.collection.objects.link(bottom_light)
|
| bottom_light.data.energy = 1000
|
| bottom_light.location = (0, 0, -10)
|
| bottom_light.rotation_euler = (0, 0, 0)
|
|
|
| return {
|
| "default_light": default_light,
|
| "top_light": top_light,
|
| "bottom_light": bottom_light
|
| }
|
|
|
|
|
| def load_object(object_path: str) -> None:
|
| """Loads a model with a supported file extension into the scene.
|
|
|
| Args:
|
| object_path (str): Path to the model file.
|
|
|
| Raises:
|
| ValueError: If the file extension is not supported.
|
|
|
| Returns:
|
| None
|
| """
|
| file_extension = object_path.split(".")[-1].lower()
|
| if file_extension is None:
|
| raise ValueError(f"Unsupported file type: {object_path}")
|
|
|
| if file_extension == "usdz":
|
|
|
| dirname = os.path.dirname(os.path.realpath(__file__))
|
| usdz_package = os.path.join(dirname, "io_scene_usdz.zip")
|
| bpy.ops.preferences.addon_install(filepath=usdz_package)
|
|
|
| addon_name = "io_scene_usdz"
|
| bpy.ops.preferences.addon_enable(module=addon_name)
|
|
|
| from io_scene_usdz.import_usdz import import_usdz
|
|
|
| import_usdz(context, filepath=object_path, materials=True, animations=True)
|
| return None
|
|
|
|
|
| import_function = IMPORT_FUNCTIONS[file_extension]
|
|
|
| print(f"Loading object from {object_path}")
|
| if file_extension == "blend":
|
| import_function(directory=object_path, link=False)
|
| elif file_extension in {"glb", "gltf"}:
|
| import_function(filepath=object_path, merge_vertices=True, import_shading='NORMALS')
|
| else:
|
| import_function(filepath=object_path)
|
|
|
| def delete_invisible_objects() -> None:
|
| """Deletes all invisible objects in the scene.
|
|
|
| Returns:
|
| None
|
| """
|
|
|
| bpy.ops.object.select_all(action="DESELECT")
|
| for obj in bpy.context.scene.objects:
|
| if obj.hide_viewport or obj.hide_render:
|
| obj.hide_viewport = False
|
| obj.hide_render = False
|
| obj.hide_select = False
|
| obj.select_set(True)
|
| bpy.ops.object.delete()
|
|
|
|
|
| invisible_collections = [col for col in bpy.data.collections if col.hide_viewport]
|
| for col in invisible_collections:
|
| bpy.data.collections.remove(col)
|
|
|
| def split_mesh_normal():
|
| bpy.ops.object.select_all(action="DESELECT")
|
| objs = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"]
|
| bpy.context.view_layer.objects.active = objs[0]
|
| for obj in objs:
|
| obj.select_set(True)
|
| bpy.ops.object.mode_set(mode="EDIT")
|
| bpy.ops.mesh.select_all(action='SELECT')
|
| bpy.ops.mesh.split_normals()
|
| bpy.ops.object.mode_set(mode='OBJECT')
|
| bpy.ops.object.select_all(action="DESELECT")
|
|
|
| def delete_custom_normals():
|
| for this_obj in bpy.data.objects:
|
| if this_obj.type == "MESH":
|
| bpy.context.view_layer.objects.active = this_obj
|
| bpy.ops.mesh.customdata_custom_splitnormals_clear()
|
|
|
| def override_material():
|
| new_mat = bpy.data.materials.new(name="Override0123456789")
|
| new_mat.use_nodes = True
|
| new_mat.node_tree.nodes.clear()
|
| bsdf = new_mat.node_tree.nodes.new('ShaderNodeBsdfDiffuse')
|
| bsdf.inputs[0].default_value = (0.5, 0.5, 0.5, 1)
|
| bsdf.inputs[1].default_value = 1
|
| output = new_mat.node_tree.nodes.new('ShaderNodeOutputMaterial')
|
| new_mat.node_tree.links.new(bsdf.outputs['BSDF'], output.inputs['Surface'])
|
| bpy.context.scene.view_layers['View Layer'].material_override = new_mat
|
|
|
| def unhide_all_objects() -> None:
|
| """Unhides all objects in the scene.
|
|
|
| Returns:
|
| None
|
| """
|
| for obj in bpy.context.scene.objects:
|
| obj.hide_set(False)
|
|
|
| def convert_to_meshes() -> None:
|
| """Converts all objects in the scene to meshes.
|
|
|
| Returns:
|
| None
|
| """
|
| bpy.ops.object.select_all(action="DESELECT")
|
| bpy.context.view_layer.objects.active = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"][0]
|
| for obj in bpy.context.scene.objects:
|
| obj.select_set(True)
|
| bpy.ops.object.convert(target="MESH")
|
|
|
| def triangulate_meshes() -> None:
|
| """Triangulates all meshes in the scene.
|
|
|
| Returns:
|
| None
|
| """
|
| bpy.ops.object.select_all(action="DESELECT")
|
| objs = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"]
|
| bpy.context.view_layer.objects.active = objs[0]
|
| for obj in objs:
|
| obj.select_set(True)
|
| bpy.ops.object.mode_set(mode="EDIT")
|
| bpy.ops.mesh.reveal()
|
| bpy.ops.mesh.select_all(action="SELECT")
|
| bpy.ops.mesh.quads_convert_to_tris(quad_method="BEAUTY", ngon_method="BEAUTY")
|
| bpy.ops.object.mode_set(mode="OBJECT")
|
| bpy.ops.object.select_all(action="DESELECT")
|
|
|
| def scene_bbox() -> Tuple[Vector, Vector]:
|
| """Returns the bounding box of the scene.
|
|
|
| Taken from Shap-E rendering script
|
| (https://github.com/openai/shap-e/blob/main/shap_e/rendering/blender/blender_script.py#L68-L82)
|
|
|
| Returns:
|
| Tuple[Vector, Vector]: The minimum and maximum coordinates of the bounding box.
|
| """
|
| bbox_min = (math.inf,) * 3
|
| bbox_max = (-math.inf,) * 3
|
| found = False
|
| scene_meshes = [obj for obj in bpy.context.scene.objects.values() if isinstance(obj.data, bpy.types.Mesh)]
|
| for obj in scene_meshes:
|
| found = True
|
| for coord in obj.bound_box:
|
| coord = Vector(coord)
|
| 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 normalize_scene() -> Tuple[float, Vector]:
|
| """Normalizes the scene by scaling and translating it to fit in a unit cube centered
|
| at the origin.
|
|
|
| Mostly taken from the Point-E / Shap-E rendering script
|
| (https://github.com/openai/point-e/blob/main/point_e/evals/scripts/blender_script.py#L97-L112),
|
| but fix for multiple root objects: (see bug report here:
|
| https://github.com/openai/shap-e/pull/60).
|
|
|
| Returns:
|
| Tuple[float, Vector]: The scale factor and the offset applied to the scene.
|
| """
|
| scene_root_objects = [obj for obj in bpy.context.scene.objects.values() if not obj.parent]
|
| if len(scene_root_objects) > 1:
|
|
|
| scene = bpy.data.objects.new("ParentEmpty", None)
|
| bpy.context.scene.collection.objects.link(scene)
|
|
|
|
|
| for obj in scene_root_objects:
|
| obj.parent = scene
|
| else:
|
| scene = scene_root_objects[0]
|
|
|
| bbox_min, bbox_max = scene_bbox()
|
| scale = 1 / max(bbox_max - bbox_min)
|
| scene.scale = scene.scale * scale
|
|
|
|
|
| bpy.context.view_layer.update()
|
| bbox_min, bbox_max = scene_bbox()
|
| offset = -(bbox_min + bbox_max) / 2
|
| scene.matrix_world.translation += offset
|
| bpy.ops.object.select_all(action="DESELECT")
|
|
|
| return scale, offset
|
|
|
| def get_transform_matrix(obj: bpy.types.Object) -> list:
|
| pos, rt, _ = obj.matrix_world.decompose()
|
| rt = rt.to_matrix()
|
| matrix = []
|
| for ii in range(3):
|
| a = []
|
| for jj in range(3):
|
| a.append(rt[ii][jj])
|
| a.append(pos[ii])
|
| matrix.append(a)
|
| matrix.append([0, 0, 0, 1])
|
| return matrix
|
|
|
| def main(arg):
|
| os.makedirs(arg.output_folder, exist_ok=True)
|
|
|
|
|
| init_render(engine=arg.engine, resolution=arg.resolution, geo_mode=arg.geo_mode)
|
| outputs, spec_nodes = init_nodes(
|
| save_depth=arg.save_depth,
|
| save_normal=arg.save_normal,
|
| save_albedo=arg.save_albedo,
|
| save_mist=arg.save_mist
|
| )
|
| if arg.object.endswith(".blend"):
|
| delete_invisible_objects()
|
| else:
|
| init_scene()
|
| load_object(arg.object)
|
| if arg.split_normal:
|
| split_mesh_normal()
|
|
|
| print('[INFO] Scene initialized.')
|
|
|
|
|
| scale, offset = normalize_scene()
|
| print('[INFO] Scene normalized.')
|
|
|
|
|
| cam = init_camera()
|
| init_lighting()
|
| print('[INFO] Camera and lighting initialized.')
|
|
|
|
|
| if arg.geo_mode:
|
| override_material()
|
|
|
|
|
| to_export = {
|
| "aabb": [[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]],
|
| "scale": scale,
|
| "offset": [offset.x, offset.y, offset.z],
|
| "frames": []
|
| }
|
| views = json.loads(arg.views)
|
| for i, view in enumerate(views):
|
| cam.location = (
|
| view['radius'] * np.cos(view['yaw']) * np.cos(view['pitch']),
|
| view['radius'] * np.sin(view['yaw']) * np.cos(view['pitch']),
|
| view['radius'] * np.sin(view['pitch'])
|
| )
|
| cam.data.lens = 16 / np.tan(view['fov'] / 2)
|
|
|
| if arg.save_depth:
|
| spec_nodes['depth_map'].inputs[1].default_value = view['radius'] - 0.5 * np.sqrt(3)
|
| spec_nodes['depth_map'].inputs[2].default_value = view['radius'] + 0.5 * np.sqrt(3)
|
|
|
| bpy.context.scene.render.filepath = os.path.join(arg.output_folder, f'{i:03d}.png')
|
| for name, output in outputs.items():
|
| output.file_slots[0].path = os.path.join(arg.output_folder, f'{i:03d}_{name}')
|
|
|
|
|
| bpy.ops.render.render(write_still=True)
|
| bpy.context.view_layer.update()
|
| for name, output in outputs.items():
|
| ext = EXT[output.format.file_format]
|
| path = glob.glob(f'{output.file_slots[0].path}*.{ext}')[0]
|
| os.rename(path, f'{output.file_slots[0].path}.{ext}')
|
|
|
|
|
| metadata = {
|
| "file_path": f'{i:03d}.png',
|
| "camera_angle_x": view['fov'],
|
| "transform_matrix": get_transform_matrix(cam)
|
| }
|
| if arg.save_depth:
|
| metadata['depth'] = {
|
| 'min': view['radius'] - 0.5 * np.sqrt(3),
|
| 'max': view['radius'] + 0.5 * np.sqrt(3)
|
| }
|
| to_export["frames"].append(metadata)
|
|
|
|
|
| with open(os.path.join(arg.output_folder, 'transforms.json'), 'w') as f:
|
| json.dump(to_export, f, indent=4)
|
|
|
| if arg.save_mesh:
|
|
|
| unhide_all_objects()
|
| convert_to_meshes()
|
| triangulate_meshes()
|
| print('[INFO] Meshes triangulated.')
|
|
|
|
|
| bpy.ops.export_mesh.ply(filepath=os.path.join(arg.output_folder, 'mesh.ply'))
|
|
|
|
|
| if __name__ == '__main__':
|
| parser = argparse.ArgumentParser(description='Renders given obj file by rotation a camera around it.')
|
| parser.add_argument('--views', type=str, help='JSON string of views. Contains a list of {yaw, pitch, radius, fov} object.')
|
| parser.add_argument('--object', type=str, help='Path to the 3D model file to be rendered.')
|
| parser.add_argument('--output_folder', type=str, default='/tmp', help='The path the output will be dumped to.')
|
| parser.add_argument('--resolution', type=int, default=512, help='Resolution of the images.')
|
| parser.add_argument('--engine', type=str, default='CYCLES', help='Blender internal engine for rendering. E.g. CYCLES, BLENDER_EEVEE, ...')
|
| parser.add_argument('--geo_mode', action='store_true', help='Geometry mode for rendering.')
|
| parser.add_argument('--save_depth', action='store_true', help='Save the depth maps.')
|
| parser.add_argument('--save_normal', action='store_true', help='Save the normal maps.')
|
| parser.add_argument('--save_albedo', action='store_true', help='Save the albedo maps.')
|
| parser.add_argument('--save_mist', action='store_true', help='Save the mist distance maps.')
|
| parser.add_argument('--split_normal', action='store_true', help='Split the normals of the mesh.')
|
| parser.add_argument('--save_mesh', action='store_true', help='Save the mesh as a .ply file.')
|
| argv = sys.argv[sys.argv.index("--") + 1:]
|
| args = parser.parse_args(argv)
|
|
|
| main(args)
|
| |