summaryrefslogtreecommitdiff
path: root/cnn_v3
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-03-22 09:49:52 +0100
committerskal <pascal.massimino@gmail.com>2026-03-22 09:49:52 +0100
commitff01cd4659777704a314d644683bf57ae98e341d (patch)
tree7d64461eb195c89c8baec0c4442f95b11c4eb2c5 /cnn_v3
parent90b53dacb460855d1efef95dbc15a078bf5aa4da (diff)
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.
Diffstat (limited to 'cnn_v3')
-rw-r--r--cnn_v3/training/blender_export.py117
1 files changed, 81 insertions, 36 deletions
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:")