Files
HartOMat/blender-renderer/blender_render.py
T
Hartmut ee6eb34b4c feat: GPU rendering + material matching + perf improvements
- GPU: fix Cycles device activation order — set compute_device_type
  BEFORE engine init, re-set AFTER open_mainfile wipes preferences
- GPU: remove _mark_sharp_and_seams edit-mode loop (redundant with
  Blender 5.0 shade_smooth_by_angle), saves ~200s/render on 175 parts
- Material: fix _AFN suffix mismatch — build AF-stripped mat_map keys
  and add prefix fallback in _apply_material_library (blender_render.py)
- Material: production GLB now uses get_material_library_path() which
  checks active AssetLibrary instead of empty legacy system setting
- Admin: RenderTemplateTable multi-select output types (M2M frontend)
- Admin: MaterialLibraryPanel replaced with link to Asset Libraries
- UX: move Toaster to top-left to avoid dispatch button overlap
- SQLAlchemy: add .unique() to all RenderTemplate M2M collection queries
- Logging: flush=True on all Blender progress prints, stdout reconfigure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 19:05:03 +01:00

680 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Blender Python script for rendering a GLB file to PNG.
Targets Blender 5.0+ (EEVEE / Cycles).
Called by Blender:
blender --background --python blender_render.py -- \
<glb_path> <output_path> <width> <height> [engine] [samples]
engine: "cycles" (default) | "eevee"
Features:
- OCC-generated GLB: one mesh per STEP part, already in metres.
- Bounding-box-aware camera: object fills ~85 % of the frame.
- Isometric-style angle (elevation 28°, azimuth 40°).
- Dynamic clip planes.
- Standard (non-Filmic) colour management → no grey tint.
- Schaeffler green top bar + model name label via Pillow post-processing.
"""
import sys
import os
import math
import bpy
from mathutils import Vector, Matrix
# ── Colour palette (matches Three.js renderer) ───────────────────────────────
PALETTE_HEX = [
"#4C9BE8", "#E85B4C", "#4CBE72", "#E8A84C", "#A04CE8",
"#4CD4E8", "#E84CA8", "#7EC850", "#E86B30", "#5088C8",
]
def _srgb_to_linear(c: int) -> float:
"""Convert 0-255 sRGB integer to linear float."""
v = c / 255.0
return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4
def _hex_to_linear(hex_color: str) -> tuple:
"""Return (r, g, b, 1.0) in Blender linear colour space."""
h = hex_color.lstrip('#')
return (
_srgb_to_linear(int(h[0:2], 16)),
_srgb_to_linear(int(h[2:4], 16)),
_srgb_to_linear(int(h[4:6], 16)),
1.0,
)
PALETTE_LINEAR = [_hex_to_linear(h) for h in PALETTE_HEX]
# ── Parse arguments ───────────────────────────────────────────────────────────
argv = sys.argv
if "--" in argv:
argv = argv[argv.index("--") + 1:]
else:
argv = []
if len(argv) < 4:
print("Usage: blender --background --python blender_render.py -- "
"<glb_path> <output_path> <width> <height> [engine] [samples] [smooth_angle] [cycles_device] [transparent_bg]")
sys.exit(1)
import json as _json
glb_path = argv[0]
output_path = argv[1]
width = int(argv[2])
height = int(argv[3])
engine = argv[4].lower() if len(argv) > 4 else "cycles"
samples = int(argv[5]) if len(argv) > 5 else (64 if engine == "eevee" else 256)
smooth_angle = int(argv[6]) if len(argv) > 6 else 30 # degrees; 0 = flat shading
cycles_device = argv[7].lower() if len(argv) > 7 else "auto" # "auto", "gpu", "cpu"
transparent_bg = argv[8] == "1" if len(argv) > 8 else False
template_path = argv[9] if len(argv) > 9 and argv[9] else ""
target_collection = argv[10] if len(argv) > 10 else "Product"
material_library_path = argv[11] if len(argv) > 11 and argv[11] else ""
material_map_raw = argv[12] if len(argv) > 12 else "{}"
try:
material_map = _json.loads(material_map_raw) if material_map_raw else {}
except _json.JSONDecodeError:
material_map = {}
part_names_ordered_raw = argv[13] if len(argv) > 13 else "[]"
try:
part_names_ordered = _json.loads(part_names_ordered_raw) if part_names_ordered_raw else []
except _json.JSONDecodeError:
part_names_ordered = []
lighting_only = argv[14] == "1" if len(argv) > 14 else False
shadow_catcher = argv[15] == "1" if len(argv) > 15 else False
rotation_x = float(argv[16]) if len(argv) > 16 else 0.0
rotation_y = float(argv[17]) if len(argv) > 17 else 0.0
rotation_z = float(argv[18]) if len(argv) > 18 else 0.0
noise_threshold_arg = argv[19] if len(argv) > 19 else ""
denoiser_arg = argv[20] if len(argv) > 20 else ""
denoising_input_passes_arg = argv[21] if len(argv) > 21 else ""
denoising_prefilter_arg = argv[22] if len(argv) > 22 else ""
denoising_quality_arg = argv[23] if len(argv) > 23 else ""
denoising_use_gpu_arg = argv[24] if len(argv) > 24 else ""
# Validate template path: if provided it MUST exist on disk.
# Fail loudly rather than silently rendering with factory settings.
if template_path and not os.path.isfile(template_path):
print(f"[blender_render] ERROR: template_path was provided but file not found: {template_path}")
print("[blender_render] Check that the blend-templates directory is on the shared volume.")
sys.exit(1)
use_template = bool(template_path)
print(f"[blender_render] engine={engine}, samples={samples}, size={width}x{height}, smooth_angle={smooth_angle}°, device={cycles_device}, transparent={transparent_bg}")
print(f"[blender_render] part_names_ordered: {len(part_names_ordered)} entries")
if use_template:
print(f"[blender_render] template={template_path}, collection={target_collection}, lighting_only={lighting_only}")
else:
print("[blender_render] no template — using factory settings (Mode A)")
if material_library_path:
print(f"[blender_render] material_library={material_library_path}, material_map keys={list(material_map.keys())}")
# ── Helper: find or create collection by name ────────────────────────────────
def _ensure_collection(name: str):
"""Return a collection by name, creating it if needed."""
if name in bpy.data.collections:
return bpy.data.collections[name]
col = bpy.data.collections.new(name)
bpy.context.scene.collection.children.link(col)
return col
def _apply_smooth(part_obj, angle_deg):
"""Apply smooth or flat shading to a mesh object."""
bpy.context.view_layer.objects.active = part_obj
part_obj.select_set(True)
if angle_deg > 0:
try:
bpy.ops.object.shade_smooth_by_angle(angle=math.radians(angle_deg))
except AttributeError:
bpy.ops.object.shade_smooth()
part_obj.data.use_auto_smooth = True
part_obj.data.auto_smooth_angle = math.radians(angle_deg)
else:
bpy.ops.object.shade_flat()
def _assign_palette_material(part_obj, index):
"""Assign a palette colour material to a mesh part."""
color = PALETTE_LINEAR[index % len(PALETTE_LINEAR)]
mat = bpy.data.materials.new(name=f"Part_{index}")
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if bsdf:
bsdf.inputs["Base Color"].default_value = color
bsdf.inputs["Metallic"].default_value = 0.35
bsdf.inputs["Roughness"].default_value = 0.40
try:
bsdf.inputs["Specular IOR Level"].default_value = 0.5
except KeyError:
pass
part_obj.data.materials.clear()
part_obj.data.materials.append(mat)
import re as _re
def _apply_rotation(parts, rx, ry, rz):
"""Apply Euler rotation (degrees, XYZ order) to all parts around world origin.
After _import_glb the combined bbox center is at world origin,
so rotating around origin is equivalent to rotating around the assembly center.
"""
if not parts or (rx == 0.0 and ry == 0.0 and rz == 0.0):
return
from mathutils import Euler
rot_mat = Euler((math.radians(rx), math.radians(ry), math.radians(rz)), 'XYZ').to_matrix().to_4x4()
for p in parts:
p.matrix_world = rot_mat @ p.matrix_world
# Bake rotation into mesh data so camera bbox calculations see the rotated geometry
bpy.ops.object.select_all(action='DESELECT')
for p in parts:
p.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
print(f"[blender_render] applied rotation ({rx}°, {ry}°, {rz}°) to {len(parts)} parts")
def _import_glb(glb_file):
"""Import OCC-generated GLB into Blender.
OCC exports one mesh object per STEP part, already in metres.
Returns list of Blender mesh objects, centred at world origin.
"""
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.import_scene.gltf(filepath=glb_file)
parts = [o for o in bpy.context.selected_objects if o.type == 'MESH']
if not parts:
print(f"ERROR: No mesh objects imported from {glb_file}")
sys.exit(1)
print(f"[blender_render] imported {len(parts)} part(s) from GLB: "
f"{[p.name for p in parts[:5]]}")
# Centre combined bbox at world origin
all_corners = []
for p in parts:
all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box)
if all_corners:
mins = Vector((min(v.x for v in all_corners),
min(v.y for v in all_corners),
min(v.z for v in all_corners)))
maxs = Vector((max(v.x for v in all_corners),
max(v.y for v in all_corners),
max(v.z for v in all_corners)))
center = (mins + maxs) * 0.5
for p in parts:
p.location -= center
return parts
def _resolve_part_name(index, part_obj):
"""Get the STEP part name for a Blender part by index.
With GLB import, part_obj.name IS the STEP name (possibly with
Blender .NNN suffix for duplicates). Strip that suffix for lookup.
Falls back to part_names_ordered index mapping.
"""
# Strip Blender auto-suffix (.001, .002, etc.)
base_name = _re.sub(r'\.\d{3}$', '', part_obj.name)
# If the base name looks like a real STEP part name (not generic "Cube" etc.),
# use it directly
if part_names_ordered and index < len(part_names_ordered):
return part_names_ordered[index]
return base_name
def _apply_material_library(parts, mat_lib_path, mat_map):
"""Append materials from library .blend and assign to parts via material_map.
GLB-imported objects are named after STEP parts, so matching is by name
(stripping Blender .NNN suffix for duplicates). Falls back to
part_names_ordered index-based matching.
mat_map: {part_name_lower: material_name}
Parts without a match keep their current material.
"""
if not mat_lib_path or not os.path.isfile(mat_lib_path):
print(f"[blender_render] material library not found: {mat_lib_path}")
return
# Collect unique material names needed
needed = set(mat_map.values())
if not needed:
return
# Append materials from library
appended = {}
for mat_name in needed:
inner_path = f"{mat_lib_path}/Material/{mat_name}"
try:
bpy.ops.wm.append(
filepath=inner_path,
directory=f"{mat_lib_path}/Material/",
filename=mat_name,
link=False,
)
if mat_name in bpy.data.materials:
appended[mat_name] = bpy.data.materials[mat_name]
print(f"[blender_render] appended material: {mat_name}")
else:
print(f"[blender_render] WARNING: material '{mat_name}' not found after append")
except Exception as exc:
print(f"[blender_render] WARNING: failed to append material '{mat_name}': {exc}")
if not appended:
return
# Assign materials to parts — primary: name-based (GLB object names),
# secondary: index-based via part_names_ordered
assigned_count = 0
for i, part in enumerate(parts):
# Try name-based matching first (strip Blender .NNN suffix)
base_name = _re.sub(r'\.\d{3}$', '', part.name)
part_key = base_name.lower().strip()
mat_name = mat_map.get(part_key)
# Fall back to index-based matching via part_names_ordered
if not mat_name and part_names_ordered and i < len(part_names_ordered):
step_name = part_names_ordered[i]
part_key = step_name.lower().strip()
mat_name = mat_map.get(part_key)
if mat_name and mat_name in appended:
part.data.materials.clear()
part.data.materials.append(appended[mat_name])
assigned_count += 1
print(f"[blender_render] assigned '{mat_name}' to part '{part.name}'")
print(f"[blender_render] material assignment: {assigned_count}/{len(parts)} parts matched")
# ── SCENE SETUP ──────────────────────────────────────────────────────────────
if use_template:
# ── MODE B: Template-based render ────────────────────────────────────────
print(f"[blender_render] Opening template: {template_path}")
bpy.ops.wm.open_mainfile(filepath=template_path)
# Find or create target collection
target_col = _ensure_collection(target_collection)
# Import GLB (already in metres from OCC export)
parts = _import_glb(glb_path)
# Apply render position rotation (before camera/bbox calculations)
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
# Move imported parts into target collection
for part in parts:
# Remove from all existing collections
for col in list(part.users_collection):
col.objects.unlink(part)
target_col.objects.link(part)
# Apply smooth shading
for part in parts:
_apply_smooth(part, smooth_angle)
# Material assignment: library materials if available, otherwise palette
if material_library_path and material_map:
# Build lowercased material_map for matching
mat_map_lower = {k.lower(): v for k, v in material_map.items()}
_apply_material_library(parts, material_library_path, mat_map_lower)
# Parts not matched by library get palette fallback
for i, part in enumerate(parts):
if not part.data.materials or len(part.data.materials) == 0:
_assign_palette_material(part, i)
else:
for i, part in enumerate(parts):
_assign_palette_material(part, i)
# ── Shadow catcher (Cycles only, template mode only) ─────────────────────
if shadow_catcher:
sc_col_name = "Shadowcatcher"
sc_obj_name = "Shadowcatcher"
# Enable the Shadowcatcher collection in all view layers
for vl in bpy.context.scene.view_layers:
def _enable_col_recursive(layer_col):
if layer_col.collection.name == sc_col_name:
layer_col.exclude = False
layer_col.collection.hide_render = False
layer_col.collection.hide_viewport = False
return True
for child in layer_col.children:
if _enable_col_recursive(child):
return True
return False
_enable_col_recursive(vl.layer_collection)
sc_obj = bpy.data.objects.get(sc_obj_name)
if sc_obj:
# Calculate product bbox min Z (world space)
all_world_corners = []
for part in parts:
for corner in part.bound_box:
all_world_corners.append((part.matrix_world @ Vector(corner)).z)
if all_world_corners:
sc_obj.location.z = min(all_world_corners)
print(f"[blender_render] shadow catcher enabled, plane Z={sc_obj.location.z:.4f}")
else:
print(f"[blender_render] WARNING: shadow catcher object '{sc_obj_name}' not found in template")
# lighting_only: use template World/HDRI but force auto-camera UNLESS the shadow
# catcher is enabled — in that case the template camera is already positioned to
# show both the product and its shadow on the ground plane.
needs_auto_camera = (lighting_only and not shadow_catcher) or not bpy.context.scene.camera
if lighting_only and not shadow_catcher:
print("[blender_render] lighting_only mode: using template World/HDRI, forcing auto-camera")
elif needs_auto_camera:
print("[blender_render] WARNING: template has no camera — will create auto-camera")
# Set very close near clip on template camera for mm-scale parts (now in metres)
if not needs_auto_camera and bpy.context.scene.camera:
bpy.context.scene.camera.data.clip_start = 0.001
print(f"[blender_render] template mode: {len(parts)} parts imported into collection '{target_collection}'")
else:
# ── MODE A: Factory settings (original behavior) ─────────────────────────
needs_auto_camera = True
bpy.ops.wm.read_factory_settings(use_empty=True)
parts = _import_glb(glb_path)
# Apply render position rotation (before camera/bbox calculations)
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
for i, part in enumerate(parts):
_apply_smooth(part, smooth_angle)
_assign_palette_material(part, i)
# Apply material library on top of palette colours (same logic as Mode B).
# material_library_path / material_map are parsed from argv even in Mode A
# but were previously never used here — that was the bug.
if material_library_path and material_map:
mat_map_lower = {k.lower(): v for k, v in material_map.items()}
_apply_material_library(parts, material_library_path, mat_map_lower)
# Parts not matched by the library keep their palette material (already set above)
if needs_auto_camera:
# ── Combined bounding box / bounding sphere ──────────────────────────────
all_corners = []
for part in parts:
all_corners.extend(part.matrix_world @ Vector(c) for c in part.bound_box)
bbox_min = Vector((
min(v.x for v in all_corners),
min(v.y for v in all_corners),
min(v.z for v in all_corners),
))
bbox_max = Vector((
max(v.x for v in all_corners),
max(v.y for v in all_corners),
max(v.z for v in all_corners),
))
bbox_center = (bbox_min + bbox_max) * 0.5
bbox_dims = bbox_max - bbox_min
bsphere_radius = max(bbox_dims.length * 0.5, 0.001)
print(f"[blender_render] bbox_dims={tuple(round(d,4) for d in bbox_dims)}, "
f"bsphere_radius={bsphere_radius:.4f}, center={tuple(round(c,4) for c in bbox_center)}")
# ── Lighting — only in Mode A (factory settings) ─────────────────────────
# In template mode the .blend file provides its own World/HDRI lighting.
# Adding auto-lights would overpower the template's intended look.
if not use_template:
light_dist = bsphere_radius * 6.0
bpy.ops.object.light_add(type='SUN', location=(
bbox_center.x + light_dist * 0.5,
bbox_center.y - light_dist * 0.35,
bbox_center.z + light_dist,
))
sun = bpy.context.active_object
sun.data.energy = 4.0
sun.rotation_euler = (math.radians(45), 0, math.radians(30))
bpy.ops.object.light_add(type='AREA', location=(
bbox_center.x - light_dist * 0.4,
bbox_center.y + light_dist * 0.4,
bbox_center.z + light_dist * 0.7,
))
fill = bpy.context.active_object
fill.data.energy = max(800.0, bsphere_radius ** 2 * 2000.0)
fill.data.size = max(4.0, bsphere_radius * 4.0)
# ── Camera ───────────────────────────────────────────────────────────────
ELEVATION_DEG = 28.0
AZIMUTH_DEG = 40.0
LENS_MM = 50.0
SENSOR_WIDTH_MM = 36.0
FILL_FACTOR = 0.85
elevation_rad = math.radians(ELEVATION_DEG)
azimuth_rad = math.radians(AZIMUTH_DEG)
cam_dir = Vector((
math.cos(elevation_rad) * math.cos(azimuth_rad),
math.cos(elevation_rad) * math.sin(azimuth_rad),
math.sin(elevation_rad),
)).normalized()
fov_h = math.atan(SENSOR_WIDTH_MM / (2.0 * LENS_MM))
fov_v = math.atan(SENSOR_WIDTH_MM * (height / width) / (2.0 * LENS_MM))
fov_used = min(fov_h, fov_v)
dist = (bsphere_radius / math.tan(fov_used)) / FILL_FACTOR
dist = max(dist, bsphere_radius * 1.5)
print(f"[blender_render] camera dist={dist:.4f}, fov={math.degrees(fov_used):.2f}°")
cam_location = bbox_center + cam_dir * dist
bpy.ops.object.camera_add(location=cam_location)
cam_obj = bpy.context.active_object
cam_obj.data.lens = LENS_MM
bpy.context.scene.camera = cam_obj
look_dir = (bbox_center - cam_location).normalized()
up_world = Vector((0.0, 0.0, 1.0))
right = look_dir.cross(up_world)
if right.length < 1e-6:
right = Vector((1.0, 0.0, 0.0))
right.normalize()
cam_up = right.cross(look_dir).normalized()
rot_mat = Matrix((
( right.x, right.y, right.z),
( cam_up.x, cam_up.y, cam_up.z),
(-look_dir.x, -look_dir.y, -look_dir.z),
)).transposed()
cam_obj.rotation_euler = rot_mat.to_euler('XYZ')
cam_obj.data.clip_start = max(dist * 0.001, 0.0001)
cam_obj.data.clip_end = dist + bsphere_radius * 3.0
print(f"[blender_render] clip {cam_obj.data.clip_start:.6f}{cam_obj.data.clip_end:.4f}")
# ── World background — only in Mode A ────────────────────────────────────
# In template mode the .blend file owns its World (HDRI, sky texture, studio
# lighting). Overwriting it would destroy the HDR look the template was
# designed to use (e.g. Alpha-HDR output types with Filmic tonemapping).
if not use_template:
world = bpy.data.worlds.new("World")
bpy.context.scene.world = world
world.use_nodes = True
bg = world.node_tree.nodes["Background"]
bg.inputs["Color"].default_value = (0.96, 0.96, 0.97, 1.0)
bg.inputs["Strength"].default_value = 0.15
# ── Render engine ─────────────────────────────────────────────────────────────
scene = bpy.context.scene
if engine == "eevee":
# Blender 4.x used 'BLENDER_EEVEE_NEXT'; Blender 5.x reverted to 'BLENDER_EEVEE'.
# Try both names so the script works across versions.
set_ok = False
for eevee_id in ('BLENDER_EEVEE', 'BLENDER_EEVEE_NEXT'):
try:
scene.render.engine = eevee_id
set_ok = True
print(f"[blender_render] EEVEE engine id: {eevee_id}")
break
except TypeError:
continue
if not set_ok:
print("[blender_render] WARNING: could not set EEVEE engine falling back to Cycles")
engine = "cycles"
if engine == "eevee":
# Sample attribute name changed across minor versions
for attr in ('taa_render_samples', 'samples'):
try:
setattr(scene.eevee, attr, samples)
print(f"[blender_render] EEVEE samples: scene.eevee.{attr}={samples}")
break
except AttributeError:
continue
if engine != "eevee": # covers both explicit Cycles and EEVEE-fallback
scene.render.engine = 'CYCLES'
scene.cycles.samples = samples
scene.cycles.use_denoising = True
scene.cycles.denoiser = denoiser_arg if denoiser_arg else 'OPENIMAGEDENOISE'
if denoising_input_passes_arg:
try: scene.cycles.denoising_input_passes = denoising_input_passes_arg
except Exception: pass
if denoising_prefilter_arg:
try: scene.cycles.denoising_prefilter = denoising_prefilter_arg
except Exception: pass
if denoising_quality_arg:
try: scene.cycles.denoising_quality = denoising_quality_arg
except Exception: pass
if denoising_use_gpu_arg:
try: scene.cycles.denoising_use_gpu = (denoising_use_gpu_arg == "1")
except AttributeError: pass
if noise_threshold_arg:
scene.cycles.use_adaptive_sampling = True
scene.cycles.adaptive_threshold = float(noise_threshold_arg)
# ── Device selection: "cpu" forces CPU, "gpu" forces GPU (fail if unavailable),
# "auto" tries GPU first and falls back to CPU.
gpu_type_found = None
if cycles_device != "cpu":
try:
cycles_prefs = bpy.context.preferences.addons['cycles'].preferences
for device_type in ('OPTIX', 'CUDA', 'HIP', 'ONEAPI'):
try:
cycles_prefs.compute_device_type = device_type
cycles_prefs.get_devices()
gpu_devs = [d for d in cycles_prefs.devices if d.type != 'CPU']
if gpu_devs:
for d in gpu_devs:
d.use = True
gpu_type_found = device_type
break
except Exception as e:
print(f"[blender_render] {device_type} not available: {e}")
except Exception as e:
print(f"[blender_render] GPU probe failed: {e}")
if gpu_type_found:
scene.cycles.device = 'GPU'
print(f"[blender_render] Cycles GPU ({gpu_type_found}), samples={samples}")
else:
scene.cycles.device = 'CPU'
print(f"[blender_render] WARNING: GPU not found — falling back to CPU, samples={samples}")
# ── Colour management ─────────────────────────────────────────────────────────
# In template mode the .blend file owns its colour management (e.g. Filmic/
# AgX for HDR, custom exposure for Alpha-HDR output types). Overwriting it
# would destroy the look the template was designed for.
# In factory-settings mode (Mode A) force Standard to avoid the grey Filmic
# tint that Blender applies by default.
if not use_template:
scene.view_settings.view_transform = 'Standard'
scene.view_settings.exposure = 0.0
scene.view_settings.gamma = 1.0
try:
scene.view_settings.look = 'None'
except Exception:
pass
# ── Render settings ───────────────────────────────────────────────────────────
scene.render.resolution_x = width
scene.render.resolution_y = height
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = 'PNG'
scene.render.filepath = output_path
scene.render.film_transparent = transparent_bg
# ── Render ────────────────────────────────────────────────────────────────────
print(f"[blender_render] Rendering → {output_path} (Blender {bpy.app.version_string})")
bpy.ops.render.render(write_still=True)
print("[blender_render] render done.")
# ── Pillow post-processing: green bar + model name label ─────────────────────
# Skip overlay for transparent renders to keep clean alpha channel
if transparent_bg:
print("[blender_render] Transparent mode — skipping Pillow overlay.")
else:
try:
from PIL import Image, ImageDraw, ImageFont
img = Image.open(output_path).convert("RGBA")
draw = ImageDraw.Draw(img)
W, H = img.size
# Schaeffler green top bar
bar_h = max(8, H // 32)
draw.rectangle([0, 0, W - 1, bar_h - 1], fill=(0, 137, 61, 255))
# Model name strip at bottom
model_name = os.path.splitext(os.path.basename(glb_path))[0]
label_h = max(20, H // 20)
img.alpha_composite(
Image.new("RGBA", (W, label_h), (30, 30, 30, 180)),
dest=(0, H - label_h),
)
font_size = max(10, label_h - 6)
font = None
for fp in [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
]:
if os.path.exists(fp):
try:
font = ImageFont.truetype(fp, font_size)
break
except Exception:
pass
if font is None:
font = ImageFont.load_default()
tb = draw.textbbox((0, 0), model_name, font=font)
text_w = tb[2] - tb[0]
draw.text(
((W - text_w) // 2, H - label_h + (label_h - (tb[3] - tb[1])) // 2),
model_name, font=font, fill=(255, 255, 255, 255),
)
img.convert("RGB").save(output_path, format="PNG")
print(f"[blender_render] Pillow overlay applied.")
except ImportError:
print("[blender_render] Pillow not in Blender Python skipping overlay.")
except Exception as exc:
print(f"[blender_render] Pillow overlay failed (non-fatal): {exc}")
print("[blender_render] Done.")