Akash Garg
adding cube sources
616f571
"""
Blender script to render images of 3D models.
This script is adopted from the Trellis rendering script:
https://github.com/microsoft/TRELLIS/blob/main/dataset_toolkits/render.py
"""
import argparse
import math
import os
import platform
import random
import sys
from pathlib import Path
from typing import Any, Callable, Dict, Generator, Literal, Optional, Tuple
import bpy
import numpy as np
from mathutils import Vector
pathdir = Path(__file__).parent
sys.path.append(pathdir.as_posix())
print(dir(bpy), bpy.__path__)
IMPORT_FUNCTIONS: Dict[str, Callable] = {
".obj": bpy.ops.wm.obj_import,
".glb": bpy.ops.import_scene.gltf,
".gltf": bpy.ops.import_scene.gltf,
}
def center_and_scale_mesh(scale_value: float = 1.0) -> None:
"""Centers and scales the scene to fit in a unit cube.
For example,
scale_value = 1.0 ==> [-0.5, 0.5]
scale_value = 2.0 ==> [-1.0, 1.0]
"""
# Get all mesh objects
mesh_objects = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"]
if not mesh_objects:
return
# Calculate bounds
min_coords = Vector((float("inf"),) * 3)
max_coords = Vector((float("-inf"),) * 3)
for obj in mesh_objects:
# Get all vertices in world space
for vertex in obj.data.vertices:
world_coord = obj.matrix_world @ vertex.co
min_coords.x = min(min_coords.x, world_coord.x)
min_coords.y = min(min_coords.y, world_coord.y)
min_coords.z = min(min_coords.z, world_coord.z)
max_coords.x = max(max_coords.x, world_coord.x)
max_coords.y = max(max_coords.y, world_coord.y)
max_coords.z = max(max_coords.z, world_coord.z)
# Calculate center and dimensions
center = (min_coords + max_coords) / 2
dimensions = max_coords - min_coords
scale = scale_value / max(
dimensions.x, dimensions.y, dimensions.z
) # Scale to fit in [-scale_value/2, scale_value/2] cube
# Create an empty to serve as the parent
empty = bpy.data.objects.new("Parent_Empty", None)
bpy.context.scene.collection.objects.link(empty)
# Parent all mesh objects to the empty
for obj in mesh_objects:
obj.parent = empty
# Move empty to center everything
empty.location = -center
# Apply scale to empty
empty.scale = (scale, scale, scale)
bpy.context.view_layer.update()
bpy.ops.object.select_all(action="DESELECT")
empty.select_set(True)
bpy.context.view_layer.objects.active = empty
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
print(f"Empty location: {empty.location}")
print(f"Empty scale: {empty.scale}")
return scale
def normalize_scene() -> None:
"""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:
The new parent object that all objects descend from.
"""
if len(list(get_scene_root_objects())) > 1:
# create an empty object to be used as a parent for all root objects
parent_empty = bpy.data.objects.new("ParentEmpty", None)
bpy.context.scene.collection.objects.link(parent_empty)
# parent all root objects to the empty object
for obj in get_scene_root_objects():
if obj != parent_empty:
obj.parent = parent_empty
bbox_min, bbox_max = scene_bbox()
scale = 1 / max(bbox_max - bbox_min)
for obj in get_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 get_scene_root_objects():
obj.matrix_world.translation += offset
bpy.ops.object.select_all(action="DESELECT")
bbox_min, bbox_max = scene_bbox()
print(f"After normalize_scene: bbox_min: {bbox_min}, bbox_max: {bbox_max}")
# unparent the camera
bpy.data.objects["Camera"].parent = None
return parent_empty
def reset_cameras() -> None:
"""Resets the cameras in the scene to a single default camera."""
# Delete all existing cameras
bpy.ops.object.select_all(action="DESELECT")
bpy.ops.object.select_by_type(type="CAMERA")
bpy.ops.object.delete()
# Create a new camera with default properties
bpy.ops.object.camera_add()
# Rename the new camera to 'NewDefaultCamera'
new_camera = bpy.context.active_object
new_camera.name = "Camera"
# Set the new camera as the active camera for the scene
scene.camera = new_camera
def get_camera_with_position(x, y, z, fov_degrees=40):
camera = bpy.data.objects["Camera"]
camera.data.angle = math.radians(fov_degrees)
camera.location = np.array([x, y, z])
direction = -camera.location
rot_quat = direction.to_track_quat("-Z", "Y")
camera.rotation_euler = rot_quat.to_euler()
return camera
def reset_scene() -> None:
"""Resets the scene to a clean state.
Returns:
None
"""
# 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)
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 = Path(object_path).suffix
if file_extension is None or file_extension == "":
raise ValueError(f"Unsupported file type: {object_path}")
# load from existing import functions
import_function = IMPORT_FUNCTIONS[file_extension]
if file_extension in {".glb", ".gltf"}:
import_function(filepath=object_path, merge_vertices=True)
else:
import_function(filepath=object_path)
def clear_lights():
bpy.ops.object.select_all(action="DESELECT")
for obj in bpy.context.scene.objects.values():
if isinstance(obj.data, bpy.types.Light):
obj.select_set(True)
bpy.ops.object.delete()
def create_light(
location,
energy=1.0,
angle=0.5 * math.pi / 180,
light_type: Literal["POINT", "SUN", "SPOT", "AREA"] = "SUN",
):
# https://blender.stackexchange.com/questions/215624/how-to-create-a-light-with-the-python-api-in-blender-2-92
light_data = bpy.data.lights.new(name="Light", type=light_type)
light_data.energy = energy
if light_type != "AREA" and light_type != "POINT":
light_data.angle = angle
light_object = bpy.data.objects.new(name="Light", object_data=light_data)
direction = -location
rot_quat = direction.to_track_quat("-Z", "Y")
light_object.rotation_euler = rot_quat.to_euler()
bpy.context.view_layer.update()
bpy.context.collection.objects.link(light_object)
light_object.location = location
def create_uniform_lights(
distance=2.0,
energy=3.0,
light_type: Literal["POINT", "SUN", "SPOT", "AREA"] = "SUN",
):
clear_lights()
create_light(Vector([1, 0, 0]) * distance, energy=energy, light_type=light_type)
create_light(-Vector([1, 0, 0]) * distance, energy=energy, light_type=light_type)
create_light(Vector([0, 1, 0]) * distance, energy=energy, light_type=light_type)
create_light(-Vector([0, 1, 0]) * distance, energy=energy, light_type=light_type)
create_light(Vector([0, 0, 1]) * distance, energy=energy, light_type=light_type)
create_light(-Vector([0, 0, 1]) * distance, energy=energy, light_type=light_type)
def create_light_at_camera_position(
camera_position: Vector,
energy=1.5,
use_shadow=False,
light_type: Literal["POINT", "SUN", "SPOT", "AREA"] = "SUN",
):
clear_lights()
create_light(camera_position, energy=energy, light_type=light_type)
# disable shadows
if not use_shadow:
for light in bpy.data.lights:
light.use_shadow = False
def set_world_background_color(
color: Tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0),
) -> None:
bpy.context.scene.world.use_nodes = True
bpy.context.scene.world.node_tree.nodes["Background"].inputs[
0
].default_value = color
bpy.context.scene.view_settings.view_transform = "Standard"
def scene_bbox(
single_obj: Optional[bpy.types.Object] = None, ignore_matrix: bool = False
) -> 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)
Args:
single_obj (Optional[bpy.types.Object], optional): If not None, only computes
the bounding box for the given object. Defaults to None.
ignore_matrix (bool, optional): Whether to ignore the object's matrix. Defaults
to False.
Raises:
RuntimeError: If there are no objects in the scene.
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
for obj in get_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 get_scene_root_objects() -> Generator[bpy.types.Object, None, None]:
"""Returns all root objects in the scene.
Yields:
Generator[bpy.types.Object, None, None]: Generator of all root objects in the
scene.
"""
for obj in bpy.context.scene.objects.values():
if not obj.parent and not isinstance(obj.data, bpy.types.Light):
yield obj
def get_scene_meshes() -> Generator[bpy.types.Object, None, None]:
"""Returns all meshes in the scene.
Yields:
Generator[bpy.types.Object, None, None]: Generator of all meshes in the scene.
"""
for obj in bpy.context.scene.objects.values():
if isinstance(obj.data, (bpy.types.Mesh)):
yield obj
def delete_missing_textures() -> Dict[str, Any]:
"""Deletes all missing textures in the scene.
Returns:
Dict[str, Any]: Dictionary with keys "count", "files", and "file_path_to_color".
"count" is the number of missing textures, "files" is a list of the missing
texture file paths, and "file_path_to_color" is a dictionary mapping the
missing texture file paths to a random color.
"""
missing_file_count = 0
out_files = []
file_path_to_color = {}
# Check all materials in the scene
for material in bpy.data.materials:
if material.use_nodes:
for node in material.node_tree.nodes:
if node.type == "TEX_IMAGE":
image = node.image
if image is not None:
file_path = bpy.path.abspath(image.filepath)
if file_path == "":
# means it's embedded
continue
if not os.path.exists(file_path):
# Find the connected Principled BSDF node
connected_node = node.outputs[0].links[0].to_node
if connected_node.type == "BSDF_PRINCIPLED":
if file_path not in file_path_to_color:
# Set a random color for the unique missing file path
random_color = [random.random() for _ in range(3)]
file_path_to_color[file_path] = random_color + [1]
connected_node.inputs[
"Base Color"
].default_value = file_path_to_color[file_path]
# Delete the TEX_IMAGE node
material.node_tree.nodes.remove(node)
missing_file_count += 1
out_files.append(image.filepath)
return {
"count": missing_file_count,
"files": out_files,
"file_path_to_color": file_path_to_color,
}
def setup_environment_lighting(envmap_path):
world = bpy.context.scene.world
world.use_nodes = True
nodes = world.node_tree.nodes
links = world.node_tree.links
# Clear existing nodes
for node in nodes:
nodes.remove(node)
# Create Background node
bg_node = nodes.new(type="ShaderNodeBackground")
bg_node.location = (0, 0)
# Create Environment Texture node
env_tex_node = nodes.new(type="ShaderNodeTexEnvironment")
env_tex_node.location = (-300, 0)
# Set the environment texture path (replace this with your file path)
env_tex_node.image = bpy.data.images.load(envmap_path)
# Create World Output node
world_output_node = nodes.new(type="ShaderNodeOutputWorld")
world_output_node.location = (300, 0)
# Link nodes
links.new(env_tex_node.outputs["Color"], bg_node.inputs["Color"])
links.new(bg_node.outputs["Background"], world_output_node.inputs["Surface"])
def create_solid_color_material(name, color):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
node_tree = mat.node_tree
color_node = node_tree.nodes.new("ShaderNodeBsdfDiffuse")
color_node.inputs["Color"].default_value = color
mat_output = node_tree.nodes["Material Output"]
node_tree.links.new(color_node.outputs["BSDF"], mat_output.inputs["Surface"])
return mat
def create_phong_material(name, color):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
node_tree = mat.node_tree
spec_node = node_tree.nodes.new("ShaderNodeBsdfPrincipled")
print(spec_node.inputs.keys())
spec_node.inputs["Base Color"].default_value = color
spec_node.inputs["Roughness"].default_value = 0.5
spec_node.inputs["Metallic"].default_value = 1.0
mat_output = node_tree.nodes["Material Output"]
node_tree.links.new(spec_node.outputs["BSDF"], mat_output.inputs["Surface"])
return mat
def render_object(
object_file: str,
num_renders: int,
output_dir: str,
transparent_background: bool = False,
environment_map: str = None,
) -> None:
"""Saves rendered images for given asset to specified output directory.
Args:
object_file (str): Path to the object file.
num_renders (int): Number of renders to save of the object.
output_dir (str): Path to the directory where the rendered images and metadata
will be saved. The rendered images will be saved in the subdirectory
`output_dir/stemname`.
transparent_background (bool): Whether to use transparent background,
otherwise the background is white.
Returns:
None
"""
os.makedirs(output_dir, exist_ok=True)
# load the object
reset_scene()
load_object(object_file)
if transparent_background:
scene.render.film_transparent = True
else:
scene.render.film_transparent = False
set_world_background_color([0.2, 0.2, 0.2, 1.0])
# normalize the scene
_ = normalize_scene()
# Set up cameras
cam = scene.objects["Camera"]
fov_degrees = 40.0
cam.data.angle = np.radians(fov_degrees)
# Set up camera constraints
cam_constraint = cam.constraints.new(type="TRACK_TO")
cam_constraint.track_axis = "TRACK_NEGATIVE_Z"
cam_constraint.up_axis = "UP_Y"
empty = bpy.data.objects.new("Empty", None)
empty.location = (0, 0, 0)
scene.collection.objects.link(empty)
cam_constraint.target = empty
cam.parent = empty
# delete all objects that are not meshes
delete_missing_textures()
if environment_map:
setup_environment_lighting(environment_map)
else:
create_uniform_lights(energy=1.0, light_type="SUN")
camera_position = [0, -2, 0]
# determine how much to orbit camera by.
stepsize = 360.0 / num_renders
def render_views(name):
for i in range(num_renders):
# set camera
_ = get_camera_with_position(
camera_position[0],
camera_position[1],
camera_position[2],
fov_degrees=fov_degrees,
)
# Set output paths with absolute paths
render_path = os.path.abspath(
os.path.join(output_dir, f"{i:03d}_{name}.png")
)
# Set file output paths
scene.render.filepath = render_path
# Make sure the output directory exists
os.makedirs(output_dir, exist_ok=True)
# Render
bpy.ops.render.render(write_still=True)
context.view_layer.objects.active = empty
empty.rotation_euler[2] += math.radians(stepsize)
# ensure that all objects have materials, if not then add a default
# one.
textured_mat = create_solid_color_material("default texture", [0.6, 0.6, 0.6, 1])
for obj in get_scene_meshes():
if obj.active_material is None:
obj.active_material = textured_mat
render_views("textured")
def enable_gpus(device_type, use_cpus=False):
preferences = bpy.context.preferences
cycles_preferences = preferences.addons["cycles"].preferences
cycles_preferences.refresh_devices()
try:
devices = cycles_preferences.devices
except:
print("No devices detected")
if device_type == "CPU":
return []
else:
raise RuntimeError(f"No devices detected, set use_cpus to True")
assert device_type in [
"CUDA",
"METAL",
"OPENCL",
"CPU",
"NONE",
], f"Unsupported device type: {device_type}"
try:
# print(devices)
iter(devices)
except TypeError:
# print("Single GPU Detected")
devices = [devices]
activated_gpus = []
for device in devices:
if device.type == "CPU":
device.use = use_cpus
else:
device.use = True
activated_gpus.append(device.name)
if device_type == "CUDA":
cycles_preferences.compute_device_type = "CUDA"
bpy.context.scene.cycles.device = "GPU"
elif device_type == "METAL":
cycles_preferences.compute_device_type = "METAL"
bpy.context.scene.cycles.device = "GPU"
elif device_type == "OPENCL":
cycles_preferences.compute_device_type = "OPENCL"
bpy.context.scene.cycles.device = "GPU"
else:
raise RuntimeError(f"Unsupported device type: {device_type}")
return activated_gpus
def set_render_settings(engine, resolution):
# Set render settings
render.engine = engine #
render.image_settings.file_format = "PNG"
render.image_settings.color_mode = "RGBA"
render.resolution_x = resolution
render.resolution_y = resolution
render.resolution_percentage = 100
# Set cycles settings
scene.cycles.device = "GPU"
scene.cycles.use_adaptive_sampling = True
scene.cycles.adaptive_threshold = 0.1
scene.cycles.samples = 64
scene.cycles.adaptive_min_samples = 1
scene.cycles.filter_width = 2
scene.cycles.use_fast_gi = True
scene.cycles.fast_gi_method = "REPLACE"
world.light_settings.ao_factor = 1.0
world.light_settings.distance = 10
scene.cycles.use_denoising = True # ML denoising
scene.cycles.denoising_use_gpu = True
# bake existing frames for faster future renders
scene.render.use_persistent_data = True
# Set eevee settings
scene.eevee.use_shadows = True
scene.eevee.use_raytracing = True
scene.eevee.ray_tracing_options.use_denoise = True
scene.eevee.use_fast_gi = True
scene.eevee.fast_gi_method = "GLOBAL_ILLUMINATION"
scene.eevee.ray_tracing_options.trace_max_roughness = 0.5
scene.eevee.fast_gi_resolution = "2"
scene.eevee.fast_gi_ray_count = 2
scene.eevee.fast_gi_step_count = 8
def print_devices():
print("Devices:")
preferences = bpy.context.preferences
cycles_preferences = preferences.addons["cycles"].preferences
cycles_preferences.refresh_devices()
devices = cycles_preferences.devices
for device in devices:
print(f' [{device.id}]<{device.type}> "{device.name}" Using: {device.use}')
print(f"Compute device type: {cycles_preferences.compute_device_type}")
print(f"Cycles device: {bpy.context.scene.cycles.device}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument(
"--object_path",
type=str,
required=False,
help="Path to the object file",
)
parser.add_argument(
"--output_dir",
type=str,
required=True,
help="Path to the directory where the rendered images and metadata will be saved.",
)
parser.add_argument(
"--engine",
type=str,
default="BLENDER_EEVEE_NEXT", # BLENDER_BLENDER_EEVEE_NEXT rasterization, better than nvdifrast, CYCLES
choices=["CYCLES", "BLENDER_EEVEE_NEXT"],
)
parser.add_argument(
"--num_renders",
type=int,
default=12,
help="Number of renders to save of the object.",
)
parser.add_argument(
"--render_resolution",
type=int,
default=512,
help="Resolution of the rendered images.",
)
parser.add_argument(
"--transparent_background",
action="store_true",
help="Whether to use transparent background",
)
parser.add_argument(
"--environment_map",
default=None,
type=str,
help="Use the given environment map for lighting",
)
argv = sys.argv[sys.argv.index("--") + 1 :]
args = parser.parse_args(argv)
context = bpy.context
scene = context.scene
render = scene.render
world = bpy.data.worlds["World"]
set_render_settings(args.engine, args.render_resolution)
# detect platform and activate GPUs
platform = platform.system()
if platform == "Darwin":
activated_gpus = enable_gpus("METAL", use_cpus=True)
elif platform == "Linux":
activated_gpus = enable_gpus("CUDA", use_cpus=False)
else:
raise RuntimeError("Unsupported platform")
print(f"Activated GPUs: {activated_gpus}")
print_devices()
render_object(
object_file=args.object_path,
num_renders=args.num_renders,
output_dir=args.output_dir,
transparent_background=args.transparent_background,
environment_map=args.environment_map,
)