SegviGen / data_toolkit /bpy_render.py
fenghora's picture
git init
84bb8a3
import bpy
import json
import math
import mathutils
import numpy as np
class BpyRenderer:
def __init__(self, resolution=512, engine="BLENDER_EEVEE", geo_mode=False, split_normal=False):
"""
engine:
- "CYCLES"
- "BLENDER_EEVEE" (Blender 3.x common)
- "BLENDER_EEVEE_NEXT" (Blender 4.x common)
- "EEVEE" / "EEVEE_NEXT" (aliases, optional)
"""
self.resolution = resolution
self.engine = engine
self.geo_mode = geo_mode
self.split_normal = split_normal
self.import_functions = self._setup_import_functions()
def _setup_import_functions(self):
import_functions = {
"obj": bpy.ops.wm.obj_import,
"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.wm.ply_import,
"abc": bpy.ops.wm.alembic_import,
"blend": bpy.ops.wm.append,
}
return import_functions
# -------------------------
# Engine helpers
# -------------------------
def _resolve_render_engine(self, requested: str) -> str:
"""
Robustly set render engine across Blender versions.
Blender 4.x may not accept "BLENDER_EEVEE" and instead uses "BLENDER_EEVEE_NEXT".
"""
req = (requested or "").upper()
if req in {"EEVEE", "BLENDER_EEVEE"}:
candidates = ["BLENDER_EEVEE", "BLENDER_EEVEE_NEXT"]
elif req in {"EEVEE_NEXT", "BLENDER_EEVEE_NEXT"}:
candidates = ["BLENDER_EEVEE_NEXT", "BLENDER_EEVEE"]
elif req in {"CYCLES"}:
candidates = ["CYCLES"]
elif req in {"WORKBENCH", "BLENDER_WORKBENCH"}:
candidates = ["BLENDER_WORKBENCH"]
else:
candidates = [requested]
last_err = None
for eng in candidates:
try:
bpy.context.scene.render.engine = eng
return eng
except Exception as e:
last_err = e
continue
raise ValueError(f"Failed to set render engine from {candidates}. Last error: {last_err}")
def _init_eevee_settings(self, render_samples: int = 64):
"""
EEVEE / EEVEE Next settings (close to huanngzh/bpy-renderer defaults).
"""
scene = bpy.context.scene
# Render basics
scene.render.image_settings.file_format = "PNG"
scene.render.image_settings.color_mode = "RGBA"
scene.render.film_transparent = True
# EEVEE quality knobs
# In Blender, eevee settings live under scene.eevee.
# These fields are used by many scripts including bpy-renderer. :contentReference[oaicite:2]{index=2}
if hasattr(scene, "eevee"):
try:
scene.eevee.taa_render_samples = int(render_samples)
except Exception:
pass
# These flags may not exist in every minor version; guard them.
for name, val in [
("use_gtao", True),
("use_ssr", True),
("use_bloom", True),
]:
if hasattr(scene.eevee, name):
try:
setattr(scene.eevee, name, val)
except Exception:
pass
# Normals quality (also in bpy-renderer init) :contentReference[oaicite:3]{index=3}
if hasattr(scene.render, "use_high_quality_normals"):
try:
scene.render.use_high_quality_normals = True
except Exception:
pass
def _init_cycles_settings(self, render_samples: int = 128):
scene = bpy.context.scene
scene.render.image_settings.file_format = "PNG"
scene.render.image_settings.color_mode = "RGBA"
scene.render.film_transparent = True
scene.cycles.samples = int(render_samples)
scene.cycles.filter_type = "BOX"
scene.cycles.filter_width = 1
scene.cycles.diffuse_bounces = 1
scene.cycles.glossy_bounces = 1
scene.cycles.transparent_max_bounces = (3 if not self.geo_mode else 0)
scene.cycles.transmission_bounces = (3 if not self.geo_mode else 1)
scene.cycles.use_denoising = True
# GPU (best-effort)
try:
scene.cycles.device = "GPU"
bpy.context.preferences.addons["cycles"].preferences.get_devices()
bpy.context.preferences.addons["cycles"].preferences.compute_device_type = "CUDA"
except Exception:
pass
# -------------------------
# Public init
# -------------------------
def init_render_settings(self):
# Resolution
bpy.context.scene.render.resolution_x = self.resolution
bpy.context.scene.render.resolution_y = self.resolution
bpy.context.scene.render.resolution_percentage = 100
# Pick engine robustly (EEVEE vs EEVEE_NEXT etc.)
actual_engine = self._resolve_render_engine(self.engine)
# Samples:
# - For geo_mode: keep minimal samples for speed
# - For RGB: moderate samples
if actual_engine == "CYCLES":
samples = 128 if not self.geo_mode else 1
self._init_cycles_settings(render_samples=samples)
else:
# EEVEE family
samples = 64 if not self.geo_mode else 1
self._init_eevee_settings(render_samples=samples)
def init_scene(self):
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(self):
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(self):
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(self, object_path):
file_extension = object_path.split(".")[-1].lower()
if file_extension not in self.import_functions:
raise ValueError(f"Unsupported file type: {file_extension}")
import_function = self.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(self):
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(self):
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 override_material(self):
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 scene_bbox(self):
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 = mathutils.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 mathutils.Vector(bbox_min), mathutils.Vector(bbox_max)
def normalize_scene(self):
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 = self.scene_bbox()
print(f"[INFO] Bounding box: {bbox_min}, {bbox_max}")
scale = 1 / max(bbox_max - bbox_min)
scene.scale = scene.scale * scale
bpy.context.view_layer.update()
bbox_min, bbox_max = self.scene_bbox()
offset = -(bbox_min + bbox_max) / 2
scene.matrix_world.translation += offset
bpy.ops.object.select_all(action="DESELECT")
return scale, offset
def set_camera_from_matrix(self, cam, transform_matrix):
matrix = mathutils.Matrix(transform_matrix)
cam.matrix_world = matrix
bpy.context.view_layer.update()
def render_from_transforms(self, file_path, transforms_json_path, output_path):
with open(transforms_json_path, "r") as f:
transforms_data = json.load(f)
self.init_render_settings()
# Load scene
if file_path.endswith(".blend"):
self.delete_invisible_objects()
else:
self.init_scene()
self.load_object(file_path)
if self.split_normal:
self.split_mesh_normal()
print("[INFO] Scene initialized.")
scale, offset = self.normalize_scene()
print(f"[INFO] Scene normalized with auto scale: {scale}, offset: {offset}")
cam = self.init_camera()
self.init_lighting()
print("[INFO] Camera and lighting initialized.")
if self.geo_mode:
self.override_material()
# NOTE: your transforms_json format seems like a list-of-dicts.
transform_matrix = transforms_data[0]["transform_matrix"]
camera_angle_x = transforms_data[0].get("camera_angle_x", None)
self.set_camera_from_matrix(cam, transform_matrix)
if camera_angle_x is not None:
cam.data.lens = 16 / np.tan(camera_angle_x / 2)
bpy.context.scene.render.filepath = output_path
bpy.ops.render.render(write_still=True)
bpy.context.view_layer.update()
def render_from_transforms(
file_path,
transforms_json_path,
output_path,
resolution=512,
engine="BLENDER_EEVEE",
geo_mode=False,
split_normal=False,
):
renderer = BpyRenderer(resolution=resolution, engine=engine, geo_mode=geo_mode, split_normal=split_normal)
return renderer.render_from_transforms(file_path, transforms_json_path, output_path)
if __name__ == "__main__":
file_path = "./assets/example.glb"
transforms_json_path = "transforms.json"
output_path = "./assets/img.png"
# Recommended:
# - engine="BLENDER_EEVEE" for Blender 3.x
# - engine="BLENDER_EEVEE_NEXT" for Blender 4.x
# This script auto-fallbacks between them.
render_from_transforms(
file_path=file_path,
transforms_json_path=transforms_json_path,
output_path=output_path,
resolution=512,
engine="BLENDER_EEVEE",
)
# import bpy
# import json
# import math
# import mathutils
# import numpy as np
# class BpyRenderer:
# def __init__(self, resolution=512, engine="CYCLES", geo_mode=False, split_normal=False):
# self.resolution = resolution
# self.engine = engine
# self.geo_mode = geo_mode
# self.split_normal = split_normal
# self.import_functions = self._setup_import_functions()
# def _setup_import_functions(self):
# import_functions = {
# "obj": bpy.ops.wm.obj_import,
# "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.wm.ply_import,
# "abc": bpy.ops.wm.alembic_import,
# "blend": bpy.ops.wm.append,
# }
# return import_functions
# def init_render_settings(self):
# bpy.context.scene.render.engine = self.engine
# bpy.context.scene.render.resolution_x = self.resolution
# bpy.context.scene.render.resolution_y = self.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
# if self.engine == "CYCLES":
# bpy.context.scene.render.engine = "CYCLES"
# bpy.context.scene.cycles.samples = 128 if not self.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 self.geo_mode else 0)
# bpy.context.scene.cycles.transmission_bounces = (3 if not self.geo_mode else 1)
# bpy.context.scene.cycles.use_denoising = True
# try:
# bpy.context.scene.cycles.device = "GPU"
# bpy.context.preferences.addons["cycles"].preferences.get_devices()
# bpy.context.preferences.addons["cycles"].preferences.compute_device_type = "CUDA"
# except:
# pass
# def init_scene(self):
# 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(self):
# 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(self):
# 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(self, object_path):
# file_extension = object_path.split(".")[-1].lower()
# if file_extension not in self.import_functions:
# raise ValueError(f"Unsupported file type: {file_extension}")
# import_function = self.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(self):
# 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 unhide_all_objects(self):
# for obj in bpy.context.scene.objects:
# obj.hide_set(False)
# def convert_to_meshes(self):
# 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(self):
# 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 split_mesh_normal(self):
# 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 override_material(self):
# 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 scene_bbox(self):
# 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 = mathutils.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 mathutils.Vector(bbox_min), mathutils.Vector(bbox_max)
# def normalize_scene(self):
# 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 = self.scene_bbox()
# print(f"[INFO] Bounding box: {bbox_min}, {bbox_max}")
# scale = 1 / max(bbox_max - bbox_min)
# scene.scale = scene.scale * scale
# bpy.context.view_layer.update()
# bbox_min, bbox_max = self.scene_bbox()
# offset = -(bbox_min + bbox_max) / 2
# scene.matrix_world.translation += offset
# bpy.ops.object.select_all(action="DESELECT")
# return scale, offset
# def set_camera_from_matrix(self, cam, transform_matrix):
# matrix = mathutils.Matrix(transform_matrix)
# cam.matrix_world = matrix
# bpy.context.view_layer.update()
# def render_from_transforms(self, file_path, transforms_json_path, output_path):
# with open(transforms_json_path, 'r') as f:
# transforms_data = json.load(f)
# self.init_render_settings()
# if file_path.endswith(".blend"):
# self.delete_invisible_objects()
# else:
# self.init_scene()
# self.load_object(file_path)
# if self.split_normal:
# self.split_mesh_normal()
# print("[INFO] Scene initialized.")
# scale, offset = self.normalize_scene()
# print(f"[INFO] Scene normalized with auto scale: {scale}, offset: {offset}")
# cam = self.init_camera()
# self.init_lighting()
# print("[INFO] Camera and lighting initialized.")
# if self.geo_mode:
# self.override_material()
# transform_matrix = transforms_data[0]["transform_matrix"]
# camera_angle_x = transforms_data[0]["camera_angle_x"]
# self.set_camera_from_matrix(cam, transform_matrix)
# if camera_angle_x is not None:
# cam.data.lens = 16 / np.tan(camera_angle_x / 2)
# bpy.context.scene.render.filepath = output_path
# bpy.ops.render.render(write_still=True)
# bpy.context.view_layer.update()
# def render_from_transforms(file_path, transforms_json_path, output_path, resolution=512, engine="CYCLES", geo_mode=False, split_normal=False):
# renderer = BpyRenderer(resolution=resolution, engine=engine, geo_mode=geo_mode, split_normal=split_normal)
# return renderer.render_from_transforms(file_path, transforms_json_path, output_path)
# if __name__ == "__main__":
# file_path = "./assets/example.glb"
# transforms_json_path = "transforms.json"
# output_path = "./assets/img.png"
# render_from_transforms(file_path=file_path, transforms_json_path=transforms_json_path, output_path=output_path)