Files
jider-label/render_model.py
T
2026-10-27 09:02:46 +00:00

511 lines
22 KiB
Python

"""Render a .blend model with a texture blended over its albedo.
Every material on every mesh is replaced with a Principled BSDF whose base
colour is the model's albedo mixed with the supplied texture, with the model's
roughness and normal (bump) maps applied. A light is placed behind the camera.
The albedo/roughness/normal images are taken from the model's existing material
unless overridden on the command line. Texture alpha is respected: transparent
areas keep the albedo.
By default --texture is a UV texture. With --project it is instead an ordinary
picture (e.g. a face) that is stretched over the model's silhouette as seen
from the camera, projected onto the visible side (leaving the stem as-is) and
baked into the UV layout first (save that with --save-projection).
Usage:
python render_model.py MODEL.blend --texture TEXTURE.png --output out.png [options]
or directly:
blender -b --disable-autoexec MODEL.blend --python render_model.py -- \
--texture TEXTURE.png --output out.png [options]
Options include --blend-mode MIX|MULTIPLY|OVERLAY|..., --blend-factor 0-1,
--project/--project-size/--project-offset-x/-y/--stem-max-width, --albedo/--roughness/--normal
overrides, --light-strength, --light-left/--light-up, --ambient, --unlit,
--azimuth, --elevation, --transparent; see --help.
"""
import argparse
import math
import os
import shutil
import sys
try:
import bmesh
import bpy
import numpy as np
from mathutils import Vector
except ImportError:
# Run with plain Python: relaunch inside Blender.
# python render_model.py MODEL.blend --texture TEX --output OUT [...]
if len(sys.argv) < 2 or not sys.argv[1].endswith(".blend"):
sys.exit("usage: python render_model.py MODEL.blend --texture TEX --output OUT [options]")
blender = os.environ.get("BLENDER", shutil.which("blender"))
if not blender:
sys.exit("blender not found on PATH (or set $BLENDER)")
os.execv(blender, [blender, "-b", "--disable-autoexec", sys.argv[1],
"--python", os.path.abspath(__file__), "--", *sys.argv[2:]])
BLEND_MODES = ["MIX", "MULTIPLY", "OVERLAY", "SCREEN", "SOFT_LIGHT", "ADD",
"DARKEN", "LIGHTEN", "COLOR", "HUE", "SATURATION", "VALUE"]
def parse_args():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--texture", required=True, help="image blended over the albedo")
p.add_argument("--output", required=True, help="output image path (PNG)")
p.add_argument("--blend-mode", default="MIX", choices=BLEND_MODES,
help="how --texture combines with the albedo")
p.add_argument("--blend-factor", type=float, default=0.5,
help="0 = albedo only, 1 = full --texture effect")
g = p.add_argument_group("projection")
g.add_argument("--project", action="store_true",
help="project --texture onto the model from the camera direction "
"instead of using it as a UV texture")
g.add_argument("--project-azimuth", type=float,
help="projection azimuth (default: --azimuth)")
g.add_argument("--project-elevation", type=float,
help="projection elevation (default: --elevation)")
g.add_argument("--project-size", type=float, default=1.0,
help="projected size relative to the model's silhouette (1 = stretched to fill it)")
g.add_argument("--project-offset-x", type=float, default=0.0,
help="shift right, as a fraction of the silhouette width")
g.add_argument("--project-offset-y", type=float, default=0.0,
help="shift up, as a fraction of the silhouette height")
g.add_argument("--stem-max-width", type=float, default=0.12,
help="UV islands in the top half narrower than this fraction of the model's "
"width count as the stem and keep the albedo (0 = no stem detection)")
g.add_argument("--project-resolution", type=int, default=1024,
help="baked UV texture size in px")
g.add_argument("--save-projection", help="also save the baked UV texture to this PNG")
p.add_argument("--albedo", help="albedo image (default: from the model's material)")
p.add_argument("--roughness", help="roughness image (default: from the model's material)")
p.add_argument("--normal", help="OpenGL normal map (default: from the model's material)")
p.add_argument("--normal-strength", type=float, default=1.0)
p.add_argument("--light-strength", type=float, default=3.0,
help="light brightness, roughly sun-equivalent W/m^2 at the model")
p.add_argument("--light-left", type=float, default=35.0,
help="degrees the light is swung to the camera's left (negative = right)")
p.add_argument("--light-up", type=float, default=35.0,
help="degrees the light is raised above the camera's view axis (negative = down)")
p.add_argument("--ambient", type=float, default=0.1,
help="uniform fill light strength (0 = none)")
p.add_argument("--unlit", action="store_true",
help="skip lighting/roughness/normal; emit the blended colour as-is")
p.add_argument("--resolution", type=int, default=1024, help="square output size in px")
p.add_argument("--samples", type=int, default=64, help="render samples")
p.add_argument("--engine", default="CYCLES", choices=["CYCLES", "BLENDER_EEVEE"])
p.add_argument("--transparent", action="store_true", help="transparent background")
p.add_argument("--background", default="0,0,0", help="background RGB 0-1 if not transparent")
p.add_argument("--azimuth", type=float, default=30.0,
help="camera rotation about Z in degrees (0 = front, from -Y)")
p.add_argument("--elevation", type=float, default=20.0, help="camera elevation in degrees")
p.add_argument("--margin", type=float, default=1.15, help="framing margin (>1 = more space)")
return p.parse_args(argv)
def bounds(meshes):
"""Centre, bounding-sphere radius and largest dimension of the meshes."""
corners = [o.matrix_world @ Vector(c) for o in meshes for c in o.bound_box]
lo = Vector(min(c[i] for c in corners) for i in range(3))
hi = Vector(max(c[i] for c in corners) for i in range(3))
return (lo + hi) / 2, (hi - lo).length / 2, max(hi - lo)
def view_basis(azimuth, elevation):
"""Direction from the model towards a camera at azimuth/elevation, plus the camera's right/up."""
az, el = math.radians(azimuth), math.radians(elevation)
direction = Vector((math.cos(el) * math.sin(az), -math.cos(el) * math.cos(az), math.sin(el)))
right = (-direction).cross(Vector((0, 0, 1)))
if right.length < 1e-6: # looking straight up/down
right = Vector((1, 0, 0))
right.normalize()
return direction, right, right.cross(-direction)
def linked_image(material, socket_name):
"""Find the image feeding a Principled BSDF input, looking through one Normal Map node."""
if not (material and material.use_nodes):
return None
for node in material.node_tree.nodes:
if node.type != "BSDF_PRINCIPLED" or not node.inputs[socket_name].is_linked:
continue
src = node.inputs[socket_name].links[0].from_node
if src.type == "NORMAL_MAP" and src.inputs["Color"].is_linked:
src = src.inputs["Color"].links[0].from_node
if src.type == "TEX_IMAGE":
return src.image
return None
def load_image(path, non_color=False):
img = bpy.data.images.load(bpy.path.abspath(path), check_existing=True)
if non_color:
img.colorspace_settings.name = "Non-Color"
return img
def uv_islands(bm, uv_layer):
"""Group faces into UV islands (faces sharing an edge with matching UVs)."""
def edge_uvs(loop):
return {loop[uv_layer].uv.to_tuple(5), loop.link_loop_next[uv_layer].uv.to_tuple(5)}
seen, islands = set(), []
for start in bm.faces:
if start.index in seen:
continue
seen.add(start.index)
stack, island = [start], []
while stack:
face = stack.pop()
island.append(face)
for loop in face.loops:
for other in loop.edge.link_loops:
if other.face.index not in seen and edge_uvs(other) == edge_uvs(loop):
seen.add(other.face.index)
stack.append(other.face)
islands.append(island)
return islands
def mark_stem(meshes, max_width):
"""Store a 'projection_keep' face-corner attribute (0 on the stem) and return non-stem vertices.
The stem is any UV island in the top half of the model whose horizontal
extent is under max_width x the model's width.
"""
center, _, _ = bounds(meshes)
corners = [o.matrix_world @ Vector(c) for o in meshes for c in o.bound_box]
model_width = max(max(c[i] for c in corners) - min(c[i] for c in corners) for i in (0, 1))
body_verts = []
for obj in meshes:
mesh, mw = obj.data, obj.matrix_world
stem_faces = set()
if max_width > 0 and mesh.uv_layers.active:
bm = bmesh.new()
bm.from_mesh(mesh)
for island in uv_islands(bm, bm.loops.layers.uv.active):
pts = [mw @ v.co for f in island for v in f.verts]
spread = max(max(p[i] for p in pts) - min(p[i] for p in pts) for i in (0, 1))
if spread < max_width * model_width and min(p.z for p in pts) > center.z:
stem_faces.update(f.index for f in island)
bm.free()
print(f"{obj.name}: {len(stem_faces)} stem faces kept as-is")
keep = mesh.attributes.get("projection_keep") or \
mesh.attributes.new("projection_keep", "FLOAT", "CORNER")
for poly in mesh.polygons:
stem = poly.index in stem_faces
for li in poly.loop_indices:
keep.data[li].value = 0.0 if stem else 1.0
if not stem:
body_verts.extend(mw @ mesh.vertices[vi].co for vi in poly.vertices)
return body_verts
def bake_projection(scene, meshes, image, args):
"""Stretch `image` over the model's silhouette as seen from the camera direction,
project it onto the front-facing surfaces (except the stem) and bake to UV RGBA."""
azimuth = args.azimuth if args.project_azimuth is None else args.project_azimuth
elevation = args.elevation if args.project_elevation is None else args.project_elevation
center, _, _ = bounds(meshes)
direction, right, up = view_basis(azimuth, elevation)
# Silhouette bounds of the body (stem excluded) in the projector's right/up plane.
body = mark_stem(meshes, args.stem_max_width)
xs = [(p - center).dot(right) for p in body]
ys = [(p - center).dot(up) for p in body]
sil_w, sil_h = max(xs) - min(xs), max(ys) - min(ys)
mid_x = (min(xs) + max(xs)) / 2 + args.project_offset_x * sil_w
mid_y = (min(ys) + max(ys)) / 2 + args.project_offset_y * sil_h
mat = bpy.data.materials.new("projection")
mat.use_nodes = True
nodes, links = mat.node_tree.nodes, mat.node_tree.links
nodes.clear()
width = sil_w * args.project_size
height = sil_h * args.project_size
origin = center + right * mid_x + up * mid_y
geo = nodes.new("ShaderNodeNewGeometry")
rel = nodes.new("ShaderNodeVectorMath")
rel.operation = "SUBTRACT"
rel.inputs[1].default_value = origin
links.new(geo.outputs["Position"], rel.inputs[0])
def image_coord(axis, span):
# (p - origin) . axis / span + 0.5
dot = nodes.new("ShaderNodeVectorMath")
dot.operation = "DOT_PRODUCT"
dot.inputs[1].default_value = axis
links.new(rel.outputs["Vector"], dot.inputs[0])
scale = nodes.new("ShaderNodeMath")
scale.operation = "MULTIPLY_ADD"
scale.inputs[1].default_value = 1 / span
scale.inputs[2].default_value = 0.5
links.new(dot.outputs["Value"], scale.inputs[0])
return scale.outputs["Value"]
coord = nodes.new("ShaderNodeCombineXYZ")
links.new(image_coord(right, width), coord.inputs["X"])
links.new(image_coord(up, height), coord.inputs["Y"])
tex = nodes.new("ShaderNodeTexImage")
tex.image = image
tex.extension = "CLIP"
links.new(coord.outputs["Vector"], tex.inputs["Vector"])
# Only surfaces facing the projector (so it doesn't bleed onto the back), minus the stem.
facing = nodes.new("ShaderNodeVectorMath")
facing.operation = "DOT_PRODUCT"
facing.inputs[1].default_value = direction
links.new(geo.outputs["Normal"], facing.inputs[0])
fade = nodes.new("ShaderNodeMapRange")
fade.inputs["From Min"].default_value = 0.0
fade.inputs["From Max"].default_value = 0.05
links.new(facing.outputs["Value"], fade.inputs["Value"])
keep = nodes.new("ShaderNodeAttribute")
keep.attribute_name = "projection_keep"
visible = nodes.new("ShaderNodeMath")
visible.operation = "MULTIPLY"
links.new(fade.outputs["Result"], visible.inputs[0])
links.new(keep.outputs["Fac"], visible.inputs[1])
mask = nodes.new("ShaderNodeMath")
mask.operation = "MULTIPLY"
links.new(tex.outputs["Alpha"], mask.inputs[0])
links.new(visible.outputs["Value"], mask.inputs[1])
emit = nodes.new("ShaderNodeEmission")
out = nodes.new("ShaderNodeOutputMaterial")
links.new(emit.outputs["Emission"], out.inputs["Surface"])
target = nodes.new("ShaderNodeTexImage") # bake target: the active, unconnected node
nodes.active = target
assign_material(meshes, mat)
bpy.ops.object.select_all(action="DESELECT")
for obj in meshes:
obj.select_set(True)
bpy.context.view_layer.objects.active = meshes[0]
scene.render.engine = "CYCLES"
scene.cycles.samples = 1
scene.cycles.device = "CPU"
res = args.project_resolution
def bake(source, name, non_color=False):
img = bpy.data.images.new(name, res, res)
if non_color:
img.colorspace_settings.name = "Non-Color"
for link in list(emit.inputs["Color"].links):
links.remove(link)
links.new(source, emit.inputs["Color"])
target.image = img
bpy.ops.object.bake(type="EMIT", margin=16)
px = np.empty(res * res * 4, dtype=np.float32)
img.pixels.foreach_get(px)
return px
rgba = bake(tex.outputs["Color"], "bake_color")
rgba[3::4] = bake(mask.outputs["Value"], "bake_mask", non_color=True)[0::4]
result = bpy.data.images.new("projected_texture", res, res, alpha=True)
result.pixels.foreach_set(rgba)
if args.save_projection:
result.filepath_raw = bpy.path.abspath(args.save_projection)
result.file_format = "PNG"
result.save()
print(f"Wrote {result.filepath_raw}")
return result
def make_material(args, maps, texture):
mat = bpy.data.materials.new("blended_texture")
mat.use_nodes = True
nodes, links = mat.node_tree.nodes, mat.node_tree.links
nodes.clear()
uv = nodes.new("ShaderNodeUVMap")
def image_node(image):
tex = nodes.new("ShaderNodeTexImage")
tex.image = image
links.new(uv.outputs["UV"], tex.inputs["Vector"])
return tex
# Base colour: albedo blended with the user texture.
user_tex = image_node(texture)
if maps["albedo"]:
mix = nodes.new("ShaderNodeMix")
mix.data_type = "RGBA"
mix.blend_type = args.blend_mode
mix.clamp_result = True
# Factor = blend factor x texture alpha, so transparent areas keep the albedo.
factor = nodes.new("ShaderNodeMath")
factor.operation = "MULTIPLY"
factor.inputs[1].default_value = args.blend_factor
links.new(user_tex.outputs["Alpha"], factor.inputs[0])
links.new(factor.outputs["Value"], mix.inputs["Factor"])
a = next(s for s in mix.inputs if s.name == "A" and s.type == "RGBA")
b = next(s for s in mix.inputs if s.name == "B" and s.type == "RGBA")
links.new(image_node(maps["albedo"]).outputs["Color"], a)
links.new(user_tex.outputs["Color"], b)
color = next(s for s in mix.outputs if s.name == "Result" and s.type == "RGBA")
else:
print("No albedo found; using --texture alone")
color = user_tex.outputs["Color"]
out = nodes.new("ShaderNodeOutputMaterial")
if args.unlit:
emit = nodes.new("ShaderNodeEmission")
links.new(color, emit.inputs["Color"])
links.new(emit.outputs["Emission"], out.inputs["Surface"])
return mat
bsdf = nodes.new("ShaderNodeBsdfPrincipled")
links.new(color, bsdf.inputs["Base Color"])
if maps["roughness"]:
maps["roughness"].colorspace_settings.name = "Non-Color"
links.new(image_node(maps["roughness"]).outputs["Color"], bsdf.inputs["Roughness"])
if maps["normal"]:
maps["normal"].colorspace_settings.name = "Non-Color"
nmap = nodes.new("ShaderNodeNormalMap")
nmap.inputs["Strength"].default_value = args.normal_strength
links.new(image_node(maps["normal"]).outputs["Color"], nmap.inputs["Color"])
links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"])
links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
return mat
def find_maps(args, meshes):
original = next((m for o in meshes for m in o.data.materials if m), None)
maps = {}
for key, socket, override in [("albedo", "Base Color", args.albedo),
("roughness", "Roughness", args.roughness),
("normal", "Normal", args.normal)]:
maps[key] = load_image(override) if override else linked_image(original, socket)
print(f"{key}: {maps[key].filepath if maps[key] else 'none'}")
return maps
def assign_material(meshes, mat):
for obj in meshes:
obj.data.materials.clear()
obj.data.materials.append(mat)
def setup_world(scene, transparent, rgb, ambient):
# Camera rays see the background colour; everything else sees a uniform
# grey fill of strength `ambient`, so the background doesn't light the model.
world = bpy.data.worlds.new("render_world")
scene.world = world
world.use_nodes = True
nodes, links = world.node_tree.nodes, world.node_tree.links
nodes.clear()
fill = nodes.new("ShaderNodeBackground")
fill.inputs["Strength"].default_value = ambient
visible = nodes.new("ShaderNodeBackground")
visible.inputs["Color"].default_value = (*rgb, 1.0)
path = nodes.new("ShaderNodeLightPath")
mix = nodes.new("ShaderNodeMixShader")
out = nodes.new("ShaderNodeOutputWorld")
links.new(path.outputs["Is Camera Ray"], mix.inputs["Fac"])
links.new(fill.outputs["Background"], mix.inputs[1])
links.new(visible.outputs["Background"], mix.inputs[2])
links.new(mix.outputs["Shader"], out.inputs["Surface"])
scene.render.film_transparent = transparent
def frame_camera(scene, meshes, azimuth, elevation, margin):
center, radius, _ = bounds(meshes)
cam_data = bpy.data.cameras.new("render_cam")
cam = bpy.data.objects.new("render_cam", cam_data)
scene.collection.objects.link(cam)
scene.camera = cam
# Distance so the bounding sphere fits the field of view.
fov = cam_data.angle
dist = radius * margin / math.sin(fov / 2)
direction, _, _ = view_basis(azimuth, elevation)
cam.location = center + direction * dist
cam.rotation_euler = (center - cam.location).to_track_quat("-Z", "Y").to_euler()
cam_data.clip_start = dist / 100
cam_data.clip_end = dist * 10
return center, dist, radius
def add_light_behind_camera(scene, center, cam_dist, radius, args):
# Point light behind the camera, swung left/up (in the camera's frame) around the model.
direction, right, up = view_basis(args.azimuth, args.elevation)
a, b = math.radians(args.light_left), math.radians(args.light_up)
direction = (direction * math.cos(a) - right * math.sin(a)) * math.cos(b) + up * math.sin(b)
light_dist = cam_dist * 1.5
data = bpy.data.lights.new("camera_light", "POINT")
data.shadow_soft_size = radius * 0.5
# Blender point light power P gives ~P / (4 pi^2 d^2) W/m^2 at distance d.
data.energy = args.light_strength * 4 * math.pi ** 2 * light_dist ** 2
light = bpy.data.objects.new("camera_light", data)
light.location = center + direction * light_dist
scene.collection.objects.link(light)
def setup_render(scene, args):
scene.render.engine = args.engine
if args.engine == "CYCLES":
scene.cycles.samples = args.samples
scene.cycles.use_denoising = False
scene.cycles.device = "CPU"
else:
scene.eevee.taa_render_samples = args.samples
r = scene.render
r.resolution_x = r.resolution_y = args.resolution
r.resolution_percentage = 100
r.image_settings.file_format = "PNG"
r.image_settings.color_mode = "RGBA" if args.transparent else "RGB"
r.filepath = bpy.path.abspath(args.output)
# "Standard" keeps texture colours faithful (Filmic/AgX would remap them).
scene.view_settings.view_transform = "Standard"
scene.view_settings.look = "None"
scene.view_settings.exposure = 0.0
scene.view_settings.gamma = 1.0
def main():
args = parse_args()
scene = bpy.context.scene
meshes = [o for o in scene.objects if o.type == "MESH" and o.visible_get()]
if not meshes:
sys.exit("No visible mesh objects in scene")
# Only the lights/camera this script adds should be used.
for o in scene.objects:
if o.type in {"LIGHT", "CAMERA"}:
o.hide_render = True
maps = find_maps(args, meshes)
texture = load_image(args.texture)
if args.project:
texture = bake_projection(scene, meshes, texture, args)
assign_material(meshes, make_material(args, maps, texture))
setup_world(scene, args.transparent, [float(v) for v in args.background.split(",")],
0.0 if args.unlit else args.ambient)
center, dist, radius = frame_camera(scene, meshes, args.azimuth, args.elevation, args.margin)
if not args.unlit:
add_light_behind_camera(scene, center, dist, radius, args)
setup_render(scene, args)
bpy.ops.render.render(write_still=True)
print(f"Wrote {scene.render.filepath}")
if __name__ == "__main__":
main()