diff options
| author | skal <pascal.massimino@gmail.com> | 2026-03-22 10:16:39 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-03-22 10:16:39 +0100 |
| commit | cdfd04179e6afd326c241e071cd082190e37b7f5 (patch) | |
| tree | 52cbe16033e05f19a52bfbaf81b533afff1d202f /cnn_v3/training/blender_export.py | |
| parent | 8fdc447ca7d490f2075e4f0604f6896de1bcda75 (diff) | |
fix(cnn_v3): native OPEN_EXR_MULTILAYER + quiet render + flexible channel names
blender_export.py:
- Replace broken compositor FileOutput approach with native OPEN_EXR_MULTILAYER
render output; all enabled passes included automatically, no socket wiring needed
- Suppress Fra:/Mem: render spam via os.dup2 fd redirect; per-frame progress
printed to stderr via render_post handler
pack_blender_sample.py:
- get_pass_r: try .R/.X/.Y/.Z/'' suffixes + aliases param for Depth→Z fallback
- combined_rgba loaded once via ("Combined","Image") loop; shared by transp+target
- Remove unused sys import
HOW_TO_CNN.md: update channel table to native EXR naming (Depth.Z, IndexOB.X,
Shadow.X), fix example command, note Shadow defaults to 255 when absent
handoff(Gemini): blender pipeline now produces correct multilayer EXR with all
G-buffer passes; pack script handles native channel naming
Diffstat (limited to 'cnn_v3/training/blender_export.py')
| -rw-r--r-- | cnn_v3/training/blender_export.py | 220 |
1 files changed, 50 insertions, 170 deletions
diff --git a/cnn_v3/training/blender_export.py b/cnn_v3/training/blender_export.py index daa5588..5ad7273 100644 --- a/cnn_v3/training/blender_export.py +++ b/cnn_v3/training/blender_export.py @@ -1,48 +1,41 @@ """ Blender export script for CNN v3 G-buffer training data. -Configures render passes and a compositor File Output node, -then renders the current scene to a multi-layer EXR. +Enables render passes and uses native OPEN_EXR_MULTILAYER output. Usage (headless): blender -b scene.blend -P blender_export.py -- --output /tmp/renders/ - # List available view layers in the blend file: + # List available view layers: blender -b scene.blend -P blender_export.py -- --view-layer ? - # Use a specific view layer: - 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 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: - Combined → training target RGBA (beauty) - DiffCol → albedo.rgb (pre-lighting material color) - 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 (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. +Output: one multilayer EXR per frame at {output}/0001.exr, 0002.exr, ... +Passes: Combined (beauty), DiffCol (albedo), Normal, Depth, IndexOB, Shadow. """ +import contextlib import os import sys import argparse -import shutil import bpy +@contextlib.contextmanager +def _suppress_blender_stdout(): + """Redirect C-level stdout to /dev/null to suppress Fra:/Mem: render spam.""" + sys.stdout.flush() + saved = os.dup(1) + null = os.open('/dev/null', os.O_WRONLY) + os.dup2(null, 1) + os.close(null) + try: + yield + finally: + sys.stdout.flush() + os.dup2(saved, 1) + os.close(saved) + + def parse_args(): # Blender passes its own argv; our args follow '--'. argv = sys.argv @@ -52,8 +45,7 @@ def parse_args(): ) parser.add_argument( "--output", default="//renders/", - help="Base output directory for compositor File Output node. " - "Blender appends frame number (e.g. 0001.exr). " + help="Output directory; frames are written as 0001.exr, 0002.exr, … " "Use // for blend file directory. Default: //renders/", ) parser.add_argument("--width", type=int, default=640, help="Render width in pixels (default: 640)") @@ -98,39 +90,6 @@ def _resolve_view_layer(scene, name): _die(f"view layer '{name}' not found. Available: {available}") -def _get_compositor_tree(scene): - """Return the compositor node tree, creating it if necessary. - - 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" - ) - return scene.compositing_node_group - # 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) - - -def _set_output_path(node, path): - """Set the output path on a CompositorNodeOutputFile (API differs by version).""" - for attr in ("base_path", "directory"): - if hasattr(node, attr): - setattr(node, attr, path) - return - candidates = [a for a in dir(node) if "path" in a.lower() or "dir" in a.lower() or "file" in a.lower()] - _die("cannot find path attribute on CompositorNodeOutputFile.", candidates) - def configure_scene(args): """Configure render settings and enable G-buffer passes.""" @@ -139,9 +98,7 @@ def configure_scene(args): 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.") + print(" NOTE: Blender 5.x — using native OPEN_EXR_MULTILAYER output.") scene.render.engine = "CYCLES" scene.render.resolution_x = args.width @@ -161,125 +118,48 @@ def configure_scene(args): 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 + # Use native multilayer EXR output — all enabled passes are included + # automatically. This is more reliable than the compositor FileOutput + # approach (which requires per-pass socket wiring that varies by Blender + # version and may silently omit passes). + scene.render.image_settings.file_format = "OPEN_EXR_MULTILAYER" + scene.render.image_settings.exr_codec = "ZIP" + out_abs = os.path.abspath(args.output) + os.makedirs(out_abs, exist_ok=True) + scene.render.filepath = out_abs + os.sep # trailing sep → {out}/0001.exr - # 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 + # Disable compositor post-processing so we capture raw G-buffer data. + if hasattr(scene.render, "use_compositing"): + scene.render.use_compositing = False 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 - + print(f" Output: {out_abs}/") -# 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]) -# bpy.data.node_groups.remove(ng) -# -# ['Image', 'Alpha', 'Depth', 'Normal', 'UV', 'Vector', 'Position', -# 'Deprecated', 'Deprecated', -# 'Shadow', 'Ambient Occlusion', -# 'Deprecated', 'Deprecated', 'Deprecated', -# 'Object Index', 'Material Index', 'Mist', 'Emission', 'Environment', -# 'Diffuse Direct', 'Diffuse Indirect', 'Diffuse Color', -# 'Glossy Direct', 'Glossy Indirect', 'Glossy Color', -# 'Transmission Direct', 'Transmission Indirect', 'Transmission Color', -# 'Subsurface Direct', 'Subsurface Indirect', 'Subsurface Color'] -# -# Table: (RenderLayer socket name, EXR layer name, Blender 5+ slot type) -PASS_SOCKETS = [ - ("Image", "Combined", "RGBA"), # beauty / target - ("Diffuse Color","DiffCol", "RGBA"), # albedo - ("Normal", "Normal", "VECTOR"), # world normals - ("Depth", "Z", "FLOAT"), # view-space depth - ("Object Index", "IndexOB", "FLOAT"), # object index - ("Shadow", "Shadow", "FLOAT"), # shadow - ("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) - out_node.format.file_format = "OPEN_EXR_MULTILAYER" - out_node.format.exr_codec = "ZIP" - _set_output_path(out_node, args.output) - if hasattr(out_node, "file_name"): - out_node.file_name = "" # Blender 5: clear prefix so output is just ####.exr - - # 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) - - if hasattr(out_node, "file_output_items"): - for _, layer_name, sock_type in PASS_SOCKETS: - slots.new(sock_type, layer_name) - else: - _, first_layer, _ = PASS_SOCKETS[0] - slots[0].path = first_layer - for _, layer_name, _ in PASS_SOCKETS[1:]: - slots.new(layer_name) +def main(): + args = parse_args() + configure_scene(args) - # 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((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) - else: - print(f"[blender_export] WARNING: cannot link '{socket_name}' → '{layer_name}'. Skipping.") + # Render with render-progress output suppressed. Per-frame status is + # printed via a render_post handler to stderr (always visible). + out_abs = os.path.abspath(args.output) - print(f"[blender_export] Compositor configured. Output → {args.output}") - print(" Layers: " + ", ".join(ln for _, ln, _ in PASS_SOCKETS)) + def _on_render_post(scene, depsgraph=None): + print(f"[blender_export] Frame {scene.frame_current} done.", file=sys.stderr, flush=True) + bpy.app.handlers.render_post.append(_on_render_post) + try: + with _suppress_blender_stdout(): + bpy.ops.render.render(animation=True) + finally: + bpy.app.handlers.render_post.remove(_on_render_post) -def main(): - args = parse_args() - discard_dir = configure_scene(args) - configure_compositor(args) - bpy.ops.render.render(animation=True) - shutil.rmtree(discard_dir, ignore_errors=True) print("[blender_export] Render complete.") # 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:") print(f" for exr in {out_abs}/*.exr; do") print(f' name=$(basename "${{exr%.exr}}")') |
