EditP23 / scripts /render_mesh.py
roi's picture
Initial commit: EditP23 project with LFS tracking for binary files
a176955
# This script is borrowed from https://github.com/allenai/objaverse-rendering
import argparse
import math
import os
from pathlib import Path
import shutil
from typing import Dict, Literal, Tuple
import bpy
from mathutils import Vector
from PIL import Image
# --- Blender Setup Functions ---
def global_settings():
"""Configures global Blender rendering settings."""
context = bpy.context
scene = context.scene
render = scene.render
render.engine = "CYCLES"
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 = 32
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
return scene
def add_lighting() -> None:
"""Adds area lights to the scene."""
# Delete the default light
if "Light" in bpy.data.objects:
bpy.data.objects["Light"].select_set(True)
bpy.ops.object.delete()
# Add a new large area light
bpy.ops.object.light_add(type="AREA")
light2 = bpy.data.lights["Area"]
light2.energy = 30000
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
# Add a fill light
bpy.ops.object.light_add(type="AREA", location=(0, 0, 2))
fill_obj = bpy.context.active_object
fill_obj.data.energy = 2000
fill_obj.scale = (10, 10, 10)
def reset_scene() -> None:
"""Resets the scene to a clean state by deleting all objects and data."""
# Delete all objects
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
# Delete all meshes
for block in bpy.data.meshes:
bpy.data.meshes.remove(block, do_unlink=True)
# Delete all materials
for material in bpy.data.materials:
bpy.data.materials.remove(material, do_unlink=True)
# Delete all textures
for texture in bpy.data.textures:
bpy.data.textures.remove(texture, do_unlink=True)
# Delete all images
for image in bpy.data.images:
bpy.data.images.remove(image, do_unlink=True)
# Delete all lights
for light in bpy.data.lights:
bpy.data.lights.remove(light, do_unlink=True)
# Delete all cameras
for cam in bpy.data.cameras:
bpy.data.cameras.remove(cam, do_unlink=True)
# Delete all empties and curves
for curve in bpy.data.curves:
bpy.data.curves.remove(curve, do_unlink=True)
# Reset world
if bpy.data.worlds:
for world in bpy.data.worlds:
bpy.data.worlds.remove(world, do_unlink=True)
# Create a new default world
bpy.context.scene.world = bpy.data.worlds.new("World")
bpy.context.view_layer.update()
def load_object(object_path: str) -> None:
"""Loads a 3D model into the scene based on its file extension."""
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)
else:
raise ValueError(f"Unsupported file type: {object_path}")
# --- Scene Normalization and Utility Functions ---
def scene_bbox(single_obj=None, ignore_matrix=False):
"""Calculates the bounding box of the scene or a single object."""
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():
"""Generator for all root objects in the scene."""
for obj in bpy.context.scene.objects.values():
if not obj.parent:
yield obj
def scene_meshes():
"""Generator for all mesh objects in the scene."""
for obj in bpy.context.scene.objects.values():
if isinstance(obj.data, (bpy.types.Mesh)):
yield obj
def normalize_scene(target_scale=1.0):
"""Normalizes the scene: scales to fit target size and centers at the origin."""
bbox_min, bbox_max = scene_bbox()
size = bbox_max - bbox_min
max_dim = max(size.x, size.y, size.z)
if max_dim == 0:
raise ValueError("Model has zero size. Cannot normalize.")
scale = target_scale / max_dim
for obj in scene_root_objects():
obj.scale = obj.scale * scale
bpy.context.view_layer.update()
bbox_min, bbox_max = scene_bbox()
center = (bbox_min + bbox_max) * 0.5
for obj in scene_root_objects():
obj.location -= center
bpy.context.view_layer.update()
# --- Camera and Lighting Setup ---
def setup_camera(scene):
"""Configures the camera and adds a tracking constraint."""
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"
return cam, cam_constraint
def _create_light(
name: str,
light_type: Literal["POINT", "SUN", "SPOT", "AREA"],
location: Tuple[float, float, float],
rotation: Tuple[float, float, float],
energy: float,
use_shadow: bool = False,
specular_factor: float = 1.0,
) -> bpy.types.Object:
"""Creates and returns a configured light object."""
light_data = bpy.data.lights.new(name=name, type=light_type)
light_object = bpy.data.objects.new(name, light_data)
bpy.context.collection.objects.link(light_object)
light_object.location = location
light_object.rotation_euler = rotation
light_data.energy = energy
light_data.use_shadow = use_shadow
light_data.specular_factor = specular_factor
return light_object
def create_lighting() -> Dict[str, bpy.types.Object]:
"""Creates a deterministic multi-directional sun lighting setup."""
# Remove existing lights
bpy.ops.object.select_all(action="DESELECT")
bpy.ops.object.select_by_type(type="LIGHT")
bpy.ops.object.delete()
# Add 4 deterministic sun lights
key_light = _create_light(
name="Key_Light",
light_type="SUN",
location=(0, 0, 0),
rotation=(0.785398, 0, -0.785398), # 45°, -45° in radians
energy=0.5,
)
fill_light = _create_light(
name="Fill_Light",
light_type="SUN",
location=(0, 0, 0),
rotation=(0.785398, 0, 2.35619), # 45°, 135°
energy=0.3,
)
rim_light = _create_light(
name="Rim_Light",
light_type="SUN",
location=(0, 0, 0),
rotation=(-0.785398, 0, -3.92699), # -45°, -225°
energy=0.5,
)
bottom_light = _create_light(
name="Bottom_Light",
light_type="SUN",
location=(0, 0, 0),
rotation=(3.14159, 0, 0), # 180° (from below)
energy=0.2,
)
return {
"key_light": key_light,
"fill_light": fill_light,
"rim_light": rim_light,
"bottom_light": bottom_light,
}
# --- Main Rendering and Image Processing Functions ---
def render_object(
object_file: str,
output_dir: str,
camera_views=[(30, 30, 1.5), (90, -20, 1.5), (150, 30, 1.5), (210, -20, 1.5), (270, 30, 1.5), (330, -20, 1.5)],
background_color=(255, 255, 255)
) -> None:
"""Renders images of an object from multiple camera views."""
scene = global_settings()
os.makedirs(output_dir, exist_ok=True)
reset_scene()
# Create and set up a new camera
bpy.ops.object.camera_add()
camera = bpy.context.object
camera.name = "Camera"
scene.collection.objects.link(camera)
scene.camera = camera
scene.view_settings.view_transform = 'Standard'
# Set background color
world = bpy.data.worlds["World"]
world.use_nodes = False
world.color = tuple(channel / 255 for channel in background_color)
scene.render.film_transparent = False
scene.world = world
# Load, normalize, and light the object
load_object(object_file)
normalize_scene()
create_lighting()
cam, cam_constraint = setup_camera(scene)
# Create an empty object for the camera to track
empty = bpy.data.objects.new("Empty", None)
scene.collection.objects.link(empty)
cam_constraint.target = empty
for i, (azim, elev, camera_dist) in enumerate(camera_views):
# Set camera position
theta = math.radians(azim)
phi = math.radians(elev)
point = (
camera_dist * math.cos(phi) * math.cos(theta),
camera_dist * math.cos(phi) * math.sin(theta),
camera_dist * math.sin(phi),
)
cam.location = point
# Render the image
render_path = os.path.join(output_dir, f"{i:02d}.png")
scene.render.filepath = render_path
bpy.ops.render.render(write_still=True)
def create_tiled_grid(
image_paths=["00.png", "01.png", "02.png", "03.png", "04.png", "05.png"],
output_path="tiled_grid.png",
tile_width=320,
tile_height=320,
background_color=(255, 255, 255),
):
"""Creates a 2x3 tiled grid image from a list of six image paths."""
if len(image_paths) != 6:
print("Error: Exactly 6 image paths are required.")
return
grid_width = tile_width * 2
grid_height = tile_height * 3
grid_image = Image.new("RGB", (grid_width, grid_height), background_color)
for i, image_path in enumerate(image_paths):
img = Image.open(image_path)
img = img.resize((tile_width, tile_height))
# Handle transparency by pasting onto a solid background
if img.mode == "RGBA":
background = Image.new("RGB", (tile_width, tile_height), background_color)
background.paste(img, (0, 0), img)
img = background
x = (i % 2) * tile_width
y = (i // 2) * tile_height
grid_image.paste(img, (x, y))
grid_image.save(output_path)
print(f"Tiled grid image saved to: {output_path}")
# --- Main Execution Block ---
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Render a 3D object into a multi-view and source image format for EditP23.")
parser.add_argument("--mesh_path", type=str, required=True, help="Path to the input .glb or .fbx file.")
parser.add_argument("--output_dir", type=str, required=True, help="Directory to save the output src.png and src_mv.png.")
parser.add_argument("--camera_dist", type=float, default=1.35, help="Camera distance from the object.")
parser.add_argument("--azim_offset", type=float, default=0, help="Azimuthal offset for camera views in degrees.")
args = parser.parse_args()
RENDERS_SUBDIR = "all_renders"
BACKGROUND_COLOR = (255, 255, 255)
output_dir = Path(args.output_dir)
renders_path = output_dir / RENDERS_SUBDIR
ELEV_1 = 20
ELEV_2 = -10
elevs = [ELEV_1, ELEV_2] * 3
azims = [(30 + 60 * i + args.azim_offset) % 360 for i in range(6)]
camera_views = [(azim, elev, args.camera_dist) for azim, elev in zip(azims, elevs)] + [
((0 + args.azim_offset) % 360, ELEV_1, args.camera_dist)
]
# Render the object from different views
render_object(
args.mesh_path,
output_dir=str(renders_path),
camera_views=camera_views,
background_color=BACKGROUND_COLOR,
)
# --- Create Final Outputs ---
image_paths_for_grid = [renders_path / f"{i:02d}.png" for i in range(6)]
create_tiled_grid(
image_paths=image_paths_for_grid,
output_path=str(output_dir/"src_mv.png"),
background_color=BACKGROUND_COLOR,
)
shutil.copy(renders_path / "06.png", output_dir / "src.png")
print(f"Saved conditioning view and multi-view grid to {renders_path}.")