"""Blender Python script: single-frame still render for Flamenco. Matches the lighting, camera, materials, and post-processing of the Celery blender_render.py so that LQ and HQ renders look consistent. Usage (from Blender): blender --background --python still_render.py -- \ \ \ [template_path] [target_collection] [material_library_path] [material_map_json] """ import bpy import sys import os import json import math from mathutils import Vector, Matrix # ── Colour palette (matches blender_render.py / Three.js renderer) ─────────── PALETTE_HEX = [ "#4C9BE8", "#E85B4C", "#4CBE72", "#E8A84C", "#A04CE8", "#4CD4E8", "#E84CA8", "#7EC850", "#E86B30", "#5088C8", ] def _srgb_to_linear(c: int) -> 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: 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] SMOOTH_ANGLE = 30 # degrees # ── Helper functions ───────────────────────────────────────────────────────── 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 _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) 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() import re as _re def _scale_mm_to_m(parts): """Scale imported STL objects from mm to Blender metres (×0.001). STEP/STL coordinates are in mm; Blender's default unit is metres. Without scaling a 50 mm part appears as 50 m inside Blender — way too large relative to any template environment designed in metric units. """ if not parts: return bpy.ops.object.select_all(action='DESELECT') for p in parts: p.scale = (0.001, 0.001, 0.001) p.location *= 0.001 p.select_set(True) bpy.context.view_layer.objects.active = parts[0] bpy.ops.object.transform_apply(scale=True, location=False, rotation=False) print(f"[still_render] scaled {len(parts)} parts mm→m (×0.001)") def _apply_rotation(parts, rx, ry, rz): """Apply Euler rotation (degrees, XYZ order) to all parts around world origin.""" if not parts or (rx == 0.0 and ry == 0.0 and rz == 0.0): return import math 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 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"[still_render] applied rotation ({rx}°, {ry}°, {rz}°) to {len(parts)} parts") def _import_stl(stl_file): """Import STL into Blender, using per-part STLs if available. Checks for {stl_stem}_parts/manifest.json next to the STL file. - Per-part mode: imports each part STL, names Blender object after STEP part name. - Fallback: imports combined STL and splits by loose geometry. Returns list of Blender mesh objects, centred at origin. """ stl_dir = os.path.dirname(stl_file) stl_stem = os.path.splitext(os.path.basename(stl_file))[0] parts_dir = os.path.join(stl_dir, stl_stem + "_parts") manifest_path = os.path.join(parts_dir, "manifest.json") parts = [] if os.path.isfile(manifest_path): # ── Per-part mode ──────────────────────────────────────────────── try: with open(manifest_path, "r") as f: manifest = json.loads(f.read()) part_entries = manifest.get("parts", []) except Exception as e: print(f"[still_render] WARNING: failed to read manifest: {e}") part_entries = [] if part_entries: for entry in part_entries: part_file = os.path.join(parts_dir, entry["file"]) part_name = entry["name"] if not os.path.isfile(part_file): print(f"[still_render] WARNING: part STL missing: {part_file}") continue bpy.ops.object.select_all(action='DESELECT') bpy.ops.wm.stl_import(filepath=part_file) imported = bpy.context.selected_objects if imported: obj = imported[0] obj.name = part_name if obj.data: obj.data.name = part_name parts.append(obj) if parts: print(f"[still_render] imported {len(parts)} named parts from per-part STLs") # ── Fallback: combined STL + separate by loose ─────────────────────── if not parts: bpy.ops.wm.stl_import(filepath=stl_file) obj = bpy.context.selected_objects[0] if bpy.context.selected_objects else None if obj is None: print(f"ERROR: No objects imported from {stl_file}") sys.exit(1) bpy.context.view_layer.objects.active = obj bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS') obj.location = (0.0, 0.0, 0.0) bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.separate(type='LOOSE') bpy.ops.object.mode_set(mode='OBJECT') parts = list(bpy.context.selected_objects) print(f"[still_render] fallback: separated into {len(parts)} part(s)") return parts # ── Centre per-part imports at origin (combined bbox) ──────────────── 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, part_names_ordered): """Get the STEP part name for a Blender part by index. With per-part import, part_obj.name IS the STEP name (possibly with Blender .NNN suffix). Falls back to part_names_ordered for combined-STL mode. """ base_name = _re.sub(r'\.\d{3}$', '', part_obj.name) 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, part_names_ordered=None): """Append materials from library .blend and assign to parts via material_map. With per-part STL import, Blender 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 for combined-STL mode. 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"[still_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"[still_render] appended material: {mat_name}") else: print(f"[still_render] WARNING: material '{mat_name}' not found after append") except Exception as exc: print(f"[still_render] WARNING: failed to append material '{mat_name}': {exc}") if not appended: return # Assign materials to parts — primary: name-based (per-part STL mode), # secondary: index-based via part_names_ordered (combined STL fallback) 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"[still_render] assigned '{mat_name}' to part '{part.name}'") print(f"[still_render] material assignment: {assigned_count}/{len(parts)} parts matched") def main(): argv = sys.argv args = argv[argv.index("--") + 1:] stl_path = args[0] output_path = args[1] width = int(args[2]) height = int(args[3]) engine = args[4] samples = int(args[5]) part_colors_json = args[6] if len(args) > 6 else "{}" transparent_bg = args[7] == "1" if len(args) > 7 else False # Template + material library args (passed by schaeffler-still.js) template_path = args[8] if len(args) > 8 and args[8] else "" target_collection = args[9] if len(args) > 9 else "Product" material_library_path = args[10] if len(args) > 10 and args[10] else "" material_map_raw = args[11] if len(args) > 11 else "{}" part_names_ordered_raw = args[12] if len(args) > 12 else "[]" lighting_only = args[13] == "1" if len(args) > 13 else False cycles_device = args[14].lower() if len(args) > 14 else "auto" # "auto", "gpu", "cpu" shadow_catcher = args[15] == "1" if len(args) > 15 else False rotation_x = float(args[16]) if len(args) > 16 else 0.0 rotation_y = float(args[17]) if len(args) > 17 else 0.0 rotation_z = float(args[18]) if len(args) > 18 else 0.0 noise_threshold_arg = args[19] if len(args) > 19 else "" denoiser_arg = args[20] if len(args) > 20 else "" denoising_input_passes_arg = args[21] if len(args) > 21 else "" denoising_prefilter_arg = args[22] if len(args) > 22 else "" denoising_quality_arg = args[23] if len(args) > 23 else "" denoising_use_gpu_arg = args[24] if len(args) > 24 else "" os.makedirs(os.path.dirname(output_path), exist_ok=True) try: part_colors = json.loads(part_colors_json) except json.JSONDecodeError: part_colors = {} try: material_map = json.loads(material_map_raw) if material_map_raw else {} except json.JSONDecodeError: material_map = {} try: part_names_ordered = json.loads(part_names_ordered_raw) if part_names_ordered_raw else [] except json.JSONDecodeError: part_names_ordered = [] # Validate template path: if provided it MUST exist on disk. # A missing template is a configuration error — fail loudly rather than # silently falling back to factory-settings mode which produces renders that # look completely wrong. if template_path and not os.path.isfile(template_path): print(f"[still_render] ERROR: template_path was provided but file not found: {template_path}") print("[still_render] Ensure the blend-templates directory is accessible on this worker.") sys.exit(1) use_template = bool(template_path) print(f"[still_render] engine={engine}, samples={samples}, size={width}x{height}, transparent={transparent_bg}") print(f"[still_render] part_names_ordered: {len(part_names_ordered)} entries") if use_template: print(f"[still_render] template={template_path}, collection={target_collection}, lighting_only={lighting_only}") else: print("[still_render] no template — using factory settings (Mode A)") if material_library_path: print(f"[still_render] material_library={material_library_path}, material_map keys={list(material_map.keys())}") # ── SCENE SETUP ────────────────────────────────────────────────────────── if use_template: # ── MODE B: Template-based render ──────────────────────────────────── print(f"[still_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 and split STL parts = _import_stl(stl_path) # Scale mm→m: STEP coords are mm, Blender default unit is metres _scale_mm_to_m(parts) # 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: 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: mat_map_lower = {k.lower(): v for k, v in material_map.items()} _apply_material_library(parts, material_library_path, mat_map_lower, part_names_ordered) # 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): step_name = _resolve_part_name(i, part, part_names_ordered) color_hex = part_colors.get(step_name) if color_hex: color = _hex_to_linear(color_hex) mat = bpy.data.materials.new(name=f"Part_{i}") 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.data.materials.clear() part.data.materials.append(mat) else: _assign_palette_material(part, i) # ── Shadow catcher (Cycles only, template mode only) ───────────────── if shadow_catcher: sc_col_name = "Shadowcatcher" sc_obj_name = "Shadowcatcher" 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: all_world_z = [] for part in parts: for corner in part.bound_box: all_world_z.append((part.matrix_world @ Vector(corner)).z) if all_world_z: sc_obj.location.z = min(all_world_z) print(f"[still_render] shadow catcher enabled, plane Z={sc_obj.location.z:.4f}") else: print(f"[still_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("[still_render] lighting_only mode: using template World/HDRI, forcing auto-camera") elif needs_auto_camera: print("[still_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"[still_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_stl(stl_path) # Scale mm→m: STEP coords are mm, Blender default unit is metres _scale_mm_to_m(parts) # 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) # Material assignment: library materials if available, else part_colors/palette 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, part_names_ordered) # Palette fallback for unmatched parts for i, part in enumerate(parts): if not part.data.materials or len(part.data.materials) == 0: _assign_palette_material(part, i) else: # part_colors or palette — use index-based lookup via part_names_ordered for i, part in enumerate(parts): step_name = _resolve_part_name(i, part, part_names_ordered) color_hex = part_colors.get(step_name) if color_hex: color = _hex_to_linear(color_hex) else: color = PALETTE_LINEAR[i % len(PALETTE_LINEAR)] mat = bpy.data.materials.new(name=f"Part_{i}") 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.data.materials.clear() part.data.materials.append(mat) 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"[still_render] bbox_dims={tuple(round(d, 4) for d in bbox_dims)}, " f"bsphere_radius={bsphere_radius:.4f}") # ── 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 (isometric-style, matches blender_render.py) ────────────── 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) 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-at rotation 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 # ── 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). 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 # ── Colour management ──────────────────────────────────────────────────── # In template mode the .blend file owns its colour management settings # (e.g. Filmic/AgX for HDR, custom exposure for Alpha-HDR output types). # Overwriting them would destroy the look the template was designed for. # In factory-settings mode (Mode A) we force Standard to avoid the grey # Filmic tint that Blender applies by default. scene = bpy.context.scene 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 engine ──────────────────────────────────────────────────────── if engine == "eevee": eevee_ok = False for eevee_id in ('BLENDER_EEVEE', 'BLENDER_EEVEE_NEXT'): try: scene.render.engine = eevee_id eevee_ok = True print(f"[still_render] EEVEE engine id: {eevee_id}") break except TypeError: continue if eevee_ok: for attr in ('taa_render_samples', 'samples'): try: setattr(scene.eevee, attr, samples) break except AttributeError: continue else: print("[still_render] WARNING: EEVEE unavailable, falling back to Cycles") engine = "cycles" if engine != "eevee": 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 (warns if unavailable), # "auto" (default) tries GPU first and falls back to CPU. print(f"[still_render] cycles_device={cycles_device}") gpu_found = False 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 scene.cycles.device = 'GPU' gpu_found = True print(f"[still_render] Cycles GPU ({device_type})") break except Exception: continue except Exception: pass if not gpu_found: scene.cycles.device = 'CPU' print("[still_render] WARNING: GPU not found — falling back to CPU") # ── Render settings ────────────────────────────────────────────────────── scene.render.resolution_x = width scene.render.resolution_y = height scene.render.resolution_percentage = 100 scene.render.film_transparent = transparent_bg ext = os.path.splitext(output_path)[1].lower() if ext in ('.jpg', '.jpeg'): scene.render.image_settings.file_format = 'JPEG' scene.render.image_settings.quality = 92 else: scene.render.image_settings.file_format = 'PNG' scene.render.filepath = output_path # ── Render ─────────────────────────────────────────────────────────────── print(f"[still_render] Rendering -> {output_path} (Blender {bpy.app.version_string})") bpy.ops.render.render(write_still=True) print("[still_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("[still_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(stl_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), ) # Save in original format if ext in ('.jpg', '.jpeg'): img.convert("RGB").save(output_path, format="JPEG", quality=92) else: img.convert("RGB").save(output_path, format="PNG") print("[still_render] Pillow overlay applied.") except ImportError: print("[still_render] Pillow not available - skipping overlay.") except Exception as exc: print(f"[still_render] Pillow overlay failed (non-fatal): {exc}") print("[still_render] Done.") if __name__ == "__main__": main()