From ff01cd4659777704a314d644683bf57ae98e341d Mon Sep 17 00:00:00 2001 From: skal Date: Sun, 22 Mar 2026 09:49:52 +0100 Subject: fix(blender_export): version detection + Blender 5.x warning + cleanup - Use bpy.app.version for version detection instead of attribute sniffing - Blender 5.0.x: warn that per-pass compositor routing is broken (Combined only); compositing_node_group path kept ready for when Blender fixes this upstream - Remove all DEBUG prints and failed use_nodes=True experiment - configure_scene() returns only discard_dir (compositor always configured) - Move _SOCKET_ALIASES to module level; simplify slots/None fallback handoff(Gemini): blender_export.py stable for Blender 4.5 LTS; Blender 5.x path is forward-compatible but produces Combined-only output until upstream fix. --- cnn_v3/training/blender_export.py | 117 ++++++++++++++++++++++++++------------ 1 file changed, 81 insertions(+), 36 deletions(-) (limited to 'cnn_v3/training/blender_export.py') diff --git a/cnn_v3/training/blender_export.py b/cnn_v3/training/blender_export.py index 290a35a..daa5588 100644 --- a/cnn_v3/training/blender_export.py +++ b/cnn_v3/training/blender_export.py @@ -13,7 +13,7 @@ Usage (headless): blender -b scene.blend -P blender_export.py -- --output /tmp/renders/ --view-layer "MyLayer" --output is the base directory for the compositor File Output node. Blender appends -the slot name and frame number to each file (e.g. /tmp/renders/Combined0001.exr). +the frame number to produce ####.exr files (e.g. /tmp/renders/0001.exr). Use // as a prefix to resolve relative to the .blend file directory. G-buffer pass mapping: @@ -22,8 +22,17 @@ G-buffer pass mapping: Normal → normal.xy (world-space, oct-encode in pack_blender_sample.py) Z → depth (view-space distance, normalize in pack step) IndexOB → mat_id (object index, u8 / 255) - Shadow → shadow (invert: shadow=1 means fully lit) + Shadow → shadow (1 = fully lit) Alpha → transp. (0=opaque, 1=clear/transparent) + +Blender version notes: + ≤4.x Works: scene.node_tree compositor routes all per-pass render data. + 5.0.x BROKEN: the new post-process compositor (compositing_node_group) only + receives the Combined image; DiffCol/Normal/Z/IndexOB/Shadow are empty. + Use Blender 4.5 LTS until this is fixed upstream. + 5.x+ The compositing_node_group path is kept ready; once Blender routes + per-pass buffers to CompositorNodeRLayers in the new compositor, + this script will work without changes. """ import os @@ -44,7 +53,7 @@ def parse_args(): parser.add_argument( "--output", default="//renders/", help="Base output directory for compositor File Output node. " - "Blender appends slot name + frame number (e.g. Combined0001.exr). " + "Blender appends frame number (e.g. 0001.exr). " "Use // for blend file directory. Default: //renders/", ) parser.add_argument("--width", type=int, default=640, help="Render width in pixels (default: 640)") @@ -65,6 +74,16 @@ def _die(msg, candidates=None): sys.exit(1) +def _blender_version(): + """Return (major, minor) tuple, e.g. (4, 5) for Blender 4.5.""" + return bpy.app.version[:2] + + +def _is_blender5(): + """True on Blender 5.0+.""" + return _blender_version()[0] >= 5 + + def _resolve_view_layer(scene, name): """Return the requested view layer, defaulting to index 0. '?' lists and exits.""" available = list(scene.view_layers.keys()) @@ -81,16 +100,23 @@ def _resolve_view_layer(scene, name): def _get_compositor_tree(scene): """Return the compositor node tree, creating it if necessary. - Handles API differences between Blender <=4.x and 5.x+.""" - if hasattr(scene, "compositing_node_group"): # Blender 5.0+ + + Blender ≤4.x: scene.node_tree (activated via scene.use_nodes = True). + Blender 5.0+: scene.compositing_node_group. NOTE: as of 5.0.x the new + post-process compositor does not route per-pass render data to + CompositorNodeRLayers — only the Combined image flows through. The code + below is correct and will work automatically once Blender fixes this. + """ + if _is_blender5(): if scene.compositing_node_group is None: scene.compositing_node_group = bpy.data.node_groups.new( name="Compositor", type="CompositorNodeTree" ) - # In Blender 5.0+, assigning compositing_node_group activates compositing. return scene.compositing_node_group - if hasattr(scene, "node_tree"): # Blender <= 4.x + # Blender ≤4: use legacy scene.node_tree. + if hasattr(scene, "use_nodes"): scene.use_nodes = True + if hasattr(scene, "node_tree"): return scene.node_tree candidates = [a for a in dir(scene) if "node" in a.lower() or "compos" in a.lower()] _die("cannot find compositor node tree on scene.", candidates) @@ -107,13 +133,17 @@ def _set_output_path(node, path): def configure_scene(args): + """Configure render settings and enable G-buffer passes.""" scene = bpy.context.scene + + major, minor = _blender_version() + print(f"[blender_export] Blender {major}.{minor}") + if _is_blender5(): + print(" WARNING: Blender 5.x detected. Per-pass compositor routing is broken " + "in 5.0.x — only Combined will appear in the output EXR.") + print(" Use Blender 4.5 LTS for training data generation.") + scene.render.engine = "CYCLES" - # Suppress default PNG output — all passes are captured via compositor File Output node. - # Redirect to a tmp/ subdir next to --output; cleaned up after render (see main()). - _discard_dir = os.path.join(os.path.abspath(args.output), "tmp") - os.makedirs(_discard_dir, exist_ok=True) - scene.render.filepath = _discard_dir + os.sep scene.render.resolution_x = args.width scene.render.resolution_y = args.height scene.render.resolution_percentage = 100 @@ -128,16 +158,26 @@ def configure_scene(args): vl.use_pass_normal = True # world normals vl.use_pass_z = True # depth (Z) vl.use_pass_object_index = True # mat_id - vl.use_pass_shadow = True # shadow catcher + vl.use_pass_shadow = True # shadow # Alpha is available via the combined pass alpha channel. + # Ensure the compositor runs during render (needed for File Output node). + if hasattr(scene.render, "use_compositing"): + scene.render.use_compositing = True + + # Redirect the render engine's own output to a discard directory; the real + # output comes from the compositor File Output node written to args.output. + discard_dir = os.path.join(os.path.abspath(args.output), "tmp") + os.makedirs(discard_dir, exist_ok=True) + scene.render.filepath = discard_dir + os.sep + print(f"[blender_export] Render passes configured on ViewLayer '{vl.name}'.") print(f" Resolution: {args.width}x{args.height}") print(f" Frames: {scene.frame_start} – {scene.frame_end}") - return _discard_dir + return discard_dir -# Full CompositorNodeRLayers output socket list (Blender 5.0.1, queried via console): +# CompositorNodeRLayers output socket names (queried from Blender 5.0.1 console): # ng = bpy.data.node_groups.new("T", "CompositorNodeTree") # rl = ng.nodes.new("CompositorNodeRLayers") # print([s.name for s in rl.outputs]) @@ -153,8 +193,7 @@ def configure_scene(args): # 'Transmission Direct', 'Transmission Indirect', 'Transmission Color', # 'Subsurface Direct', 'Subsurface Indirect', 'Subsurface Color'] # -# Mapping used here (socket name → EXR layer name → Blender 5+ type): -# (render-layer socket name, EXR layer name, Blender 5+ socket type) +# Table: (RenderLayer socket name, EXR layer name, Blender 5+ slot type) PASS_SOCKETS = [ ("Image", "Combined", "RGBA"), # beauty / target ("Diffuse Color","DiffCol", "RGBA"), # albedo @@ -165,14 +204,30 @@ PASS_SOCKETS = [ ("Alpha", "Alpha", "FLOAT"), # transparency ] +# Fallback socket name aliases for older Blender versions. +_SOCKET_ALIASES = { + "Shadow": ["Shadow Catcher", "ShadowCatcher"], + "Diffuse Color": ["DiffCol", "Diffuse"], + "Depth": ["Z"], + "Object Index": ["IndexOB"], + "Alpha": ["Alpha"], +} + def configure_compositor(args): + """Build the compositor node graph: RenderLayers → FileOutput (OPEN_EXR_MULTILAYER).""" scene = bpy.context.scene + vl = _resolve_view_layer(scene, args.view_layer) tree = _get_compositor_tree(scene) tree.nodes.clear() rl_node = tree.nodes.new("CompositorNodeRLayers") rl_node.location = (0, 0) + # Bind to the correct scene + view layer so per-pass outputs are populated. + if hasattr(rl_node, "scene"): + rl_node.scene = scene + if hasattr(rl_node, "layer"): + rl_node.layer = vl.name out_node = tree.nodes.new("CompositorNodeOutputFile") out_node.location = (600, 0) @@ -182,16 +237,15 @@ def configure_compositor(args): if hasattr(out_node, "file_name"): out_node.file_name = "" # Blender 5: clear prefix so output is just ####.exr - # The node starts with one default slot; rename it first, then add the rest. - slots = getattr(out_node, "file_output_items", None) - if slots is None: - slots = getattr(out_node, "file_slots", None) + # Populate output slots. + # file_output_items (Blender 5+) starts empty; add all slots. + # file_slots (Blender ≤4) has one default slot; rename first, then append. + slots = getattr(out_node, "file_output_items", None) \ + or getattr(out_node, "file_slots", None) if slots is None: candidates = [a for a in dir(out_node) if not a.startswith("__")] _die("cannot find slots attribute on CompositorNodeOutputFile.", candidates) - # Populate slots. file_output_items (Blender 5+) starts empty; - # file_slots (older) has one default slot that must be renamed before adding more. if hasattr(out_node, "file_output_items"): for _, layer_name, sock_type in PASS_SOCKETS: slots.new(sock_type, layer_name) @@ -201,19 +255,10 @@ def configure_compositor(args): for _, layer_name, _ in PASS_SOCKETS[1:]: slots.new(layer_name) - # Socket name aliases across Blender versions. - SOCKET_ALIASES = { - "Shadow": ["Shadow Catcher", "ShadowCatcher"], - "Diffuse Color": ["DiffCol", "Diffuse"], - "Depth": ["Z"], - "Object Index": ["IndexOB"], - "Alpha": ["Alpha"], - } - - # Link each render-layer output to the file output slot by name. + # Link each RenderLayer output socket to the corresponding FileOutput slot. for socket_name, layer_name, _ in PASS_SOCKETS: - candidates = [socket_name] + SOCKET_ALIASES.get(socket_name, []) - src = next((rl_node.outputs.get(n) for n in candidates if rl_node.outputs.get(n)), None) + candidates = [socket_name] + _SOCKET_ALIASES.get(socket_name, []) + src = next((s for s in rl_node.outputs if s.name in candidates), None) dst = out_node.inputs.get(layer_name) if src and dst: tree.links.new(src, dst) @@ -232,7 +277,7 @@ def main(): shutil.rmtree(discard_dir, ignore_errors=True) print("[blender_export] Render complete.") - # Print the next-step pack command (HOW_TO_CNN.md §"Batch packing"). + # Print the next-step pack command. pack_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pack_blender_sample.py") out_abs = os.path.abspath(args.output) print("\n[blender_export] Next step — pack EXRs into sample directories:") -- cgit v1.2.3