From cdfd04179e6afd326c241e071cd082190e37b7f5 Mon Sep 17 00:00:00 2001 From: skal Date: Sun, 22 Mar 2026 10:16:39 +0100 Subject: fix(cnn_v3): native OPEN_EXR_MULTILAYER + quiet render + flexible channel names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cnn_v3/docs/HOW_TO_CNN.md | 52 ++++---- cnn_v3/training/blender_export.py | 224 ++++++++------------------------- cnn_v3/training/pack_blender_sample.py | 78 +++++++----- 3 files changed, 125 insertions(+), 229 deletions(-) diff --git a/cnn_v3/docs/HOW_TO_CNN.md b/cnn_v3/docs/HOW_TO_CNN.md index 3013e1c..69bbf6f 100644 --- a/cnn_v3/docs/HOW_TO_CNN.md +++ b/cnn_v3/docs/HOW_TO_CNN.md @@ -138,49 +138,53 @@ Produces all 20 feature channels including normals, depth, mat IDs, and shadow. #### Blender requirements -- Blender 3.x+ or 5.x+, Cycles render engine (5.x API differences handled automatically) -- Object indices set: *Properties → Object → Relations → Object Index* must be > 0 +- **Blender 4.5 LTS** recommended (`blender4` alias); 5.x also works +- Cycles render engine (set automatically by the script) +- Object indices set: *Properties → Object → Relations → Object Index* > 0 for objects you want tracked in `matid` (IndexOB pass) #### Step 1 — Render EXRs ```bash -blender -b scene.blend -P cnn_v3/training/blender_export.py -- \ - --output /tmp/renders/ \ +cd cnn_v3/training +blender4 -b input_3d/scene.blend -P blender_export.py -- \ + --output tmp/renders/frames \ --width 640 --height 360 \ - --start-frame 1 --end-frame 200 + --start-frame 1 --end-frame 200 \ + --view-layer RenderLayer ``` -The `--` separator is **required**; arguments after it are passed to the Python script, -not to Blender. `--output` is the base directory for the compositor File Output node; -Blender appends the slot name and frame number automatically (e.g. `Combined0001.exr`). -`//` is a Blender shorthand for the directory containing the `.blend` file. +The `--` separator is **required**. `blender_export.py` uses native +`OPEN_EXR_MULTILAYER` render output — all enabled passes are written +automatically. One file per frame: `{output}/0001.exr`, `0002.exr`, … +Render progress (`Fra:/Mem:` spam) is suppressed; per-frame status goes to stderr. **Available flags:** | Flag | Default | Notes | |------|---------|-------| -| `--output PATH` | `//renders/` | Base output directory; `//` = blend file directory | +| `--output PATH` | `//renders/` | Output directory; `//` = blend file directory | | `--width N` | 640 | Render resolution | | `--height N` | 360 | Render resolution | | `--start-frame N` | scene start | First frame | | `--end-frame N` | scene end | Last frame | | `--view-layer NAME` | first layer | View layer name; pass `?` to list available layers | -**Render pass → CNN channel mapping:** - -| Blender pass | EXR channels | CNN use | -|-------------|-------------|---------| -| Combined | `.R .G .B .A` | `target.png` (beauty, sRGB-converted) | -| DiffCol | `.R .G .B` | `albedo.png` (linear → sRGB gamma 2.2) | -| Normal | `.X .Y .Z` | `normal.png` (world-space, oct-encoded to RG) | -| Z | `.R` | `depth.png` (mapped as 1/(z+1) → uint16) | -| IndexOB | `.R` | `matid.png` (object index, clamped uint8) | -| Shadow | `.R` | `shadow.png` (255 = lit, 0 = shadowed) | -| Combined alpha | `.A` | `transp.png` (inverted: 0 = opaque) | - -**Pitfall:** Blender `Normal` pass uses `.X .Y .Z` channel names in the EXR, not `.R .G .B`. -`pack_blender_sample.py` handles both naming conventions automatically. +**Render pass → EXR channel → CNN file:** + +| Blender pass | Native EXR channel | CNN file | +|-------------|--------------------|---------| +| Combined | `Combined.R/G/B/A` | `target.png` (beauty, linear→sRGB) | +| DiffCol | `DiffCol.R/G/B` | `albedo.png` (linear→sRGB γ2.2) | +| Normal | `Normal.X/Y/Z` | `normal.png` (oct-encoded RG) | +| Depth | `Depth.Z` | `depth.png` (1/(z+1) → uint16) | +| IndexOB | `IndexOB.X` | `matid.png` (object index, uint8) | +| Shadow | `Shadow.X` | `shadow.png` (255=lit; defaults to 255 if absent) | +| Combined alpha | `Combined.A` | `transp.png` (1−alpha, 0=opaque) | + +**Note on Shadow pass:** Blender's Cycles Shadow pass may be absent for scenes +without shadow-casting lights or catcher objects; `pack_blender_sample.py` defaults +to 1.0 (fully lit) when the channel is missing. #### Step 2 — Pack EXRs into sample directories 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 - - -# 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) - - # 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.") - - print(f"[blender_export] Compositor configured. Output → {args.output}") - print(" Layers: " + ", ".join(ln for _, ln, _ in PASS_SOCKETS)) + print(f" Output: {out_abs}/") 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) + configure_scene(args) + + # 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) + + 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) + 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}}")') diff --git a/cnn_v3/training/pack_blender_sample.py b/cnn_v3/training/pack_blender_sample.py index 84344c1..03d540c 100644 --- a/cnn_v3/training/pack_blender_sample.py +++ b/cnn_v3/training/pack_blender_sample.py @@ -9,7 +9,7 @@ Output files: normal.png — RG uint8 (octahedral-encoded world normal in [0,1]) depth.png — R uint16 (1/(z+1) normalized to [0,1], 16-bit PNG) matid.png — R uint8 (IndexOB / 255) - shadow.png — R uint8 (1 - shadow_catcher, so 255 = fully lit) + shadow.png — R uint8 (255 = fully lit; defaults to 255 if pass absent) transp.png — R uint8 (alpha from Combined pass, 0=opaque) target.png — RGBA uint8 (Combined beauty pass) @@ -27,7 +27,6 @@ Dependencies: import argparse import os -import sys import numpy as np from PIL import Image @@ -128,13 +127,21 @@ def get_pass_rgba(channels: dict, prefix: str) -> np.ndarray: return np.concatenate([rgb, a[..., np.newaxis]], axis=-1) -def get_pass_r(channels: dict, prefix: str, default: float = 0.0) -> np.ndarray: - """Extract a single-channel pass.""" - ch = channels.get(f"{prefix}.R", channels.get(prefix, None)) - if ch is None: - h, w = next(iter(channels.values())).shape[:2] - return np.full((h, w), default, dtype=np.float32) - return ch.astype(np.float32) +def get_pass_r(channels: dict, prefix: str, default: float = 0.0, + aliases: tuple = ()) -> np.ndarray: + """Extract a single-channel pass. + + Tries multiple channel suffixes (.R, .X, .Y, .Z, none) and optional prefix + aliases to handle both compositor FileOutput naming and native OPEN_EXR_MULTILAYER + naming (e.g. 'Depth.Z' vs 'Z', 'Shadow.X' vs 'Shadow.R'). + """ + for p in (prefix,) + tuple(aliases): + for suffix in ('.R', '.X', '.Y', '.Z', ''): + ch = channels.get(f"{p}{suffix}" if suffix else p) + if ch is not None: + return ch.astype(np.float32) + h, w = next(iter(channels.values())).shape[:2] + return np.full((h, w), default, dtype=np.float32) def get_pass_xyz(channels: dict, prefix: str) -> np.ndarray: @@ -155,6 +162,10 @@ def pack_blender_sample(exr_path: str, output_dir: str) -> None: print(f"[pack_blender_sample] Loading {exr_path} …") channels, width, height = load_exr(exr_path) + # Native OPEN_EXR_MULTILAYER prefixes channels with the view-layer name, + # e.g. "RenderLayer.DiffCol.R". Strip it so all lookups use 2-part names. + if any(k.count(".") >= 2 for k in channels): + channels = {".".join(k.split(".")[1:]): v for k, v in channels.items()} print(f" Dimensions: {width}×{height}") print(f" Channels: {sorted(channels.keys())}") @@ -191,8 +202,10 @@ def pack_blender_sample(exr_path: str, output_dir: str) -> None: os.path.join(output_dir, "normal.png") ) - # ---- depth (Z pass → 1/(z+1), stored as 16-bit PNG) ---- - z_raw = get_pass_r(channels, "Z", default=0.0) + # ---- depth (Z/Depth pass → 1/(z+1), stored as 16-bit PNG) ---- + # Native OPEN_EXR_MULTILAYER stores depth as "Depth.Z"; compositor FileOutput + # uses slot name "Z" (channel may be .R or .Z). + z_raw = get_pass_r(channels, "Depth", default=0.0, aliases=("Z",)) # 1/z style: 1/(z + 1) maps z=0→1.0, z=∞→0.0 depth_norm = 1.0 / (np.clip(z_raw, 0.0, None) + 1.0) depth_norm = np.clip(depth_norm, 0.0, 1.0) @@ -209,40 +222,39 @@ def pack_blender_sample(exr_path: str, output_dir: str) -> None: os.path.join(output_dir, "matid.png") ) - # ---- shadow (Shadow pass → invert: 1=fully lit, stored u8) ---- - # Blender Shadow pass: 1=lit, 0=shadowed. We keep that convention - # (shadow=1 means fully lit), so just convert directly. + # ---- shadow (Shadow pass → 1=fully lit, stored u8; defaults to 255 if absent) ---- shadow_raw = get_pass_r(channels, "Shadow", default=1.0) shadow_u8 = (np.clip(shadow_raw, 0.0, 1.0) * 255.0).astype(np.uint8) Image.fromarray(shadow_u8, mode="L").save( os.path.join(output_dir, "shadow.png") ) - # ---- transp (Alpha from Combined pass → u8, 0=opaque) ---- - # Blender alpha: 1=opaque, 0=transparent. - # CNN convention: transp=0 means opaque, transp=1 means transparent. - # So transp = 1 - alpha. - try: - combined_rgba = get_pass_rgba(channels, "Combined") - alpha = combined_rgba[..., 3] - except KeyError: - alpha = np.ones((height, width), dtype=np.float32) - transp = 1.0 - np.clip(alpha, 0.0, 1.0) - transp_u8 = (transp * 255.0).astype(np.uint8) + # ---- load beauty (Combined or Image pass) once; used for transp + target ---- + # Native OPEN_EXR_MULTILAYER stores beauty as "Combined"; some scenes use "Image". + combined_rgba = None + for _beauty in ("Combined", "Image"): + try: + combined_rgba = get_pass_rgba(channels, _beauty) + break + except KeyError: + pass + if combined_rgba is None: + print(" WARNING: Combined/Image pass not found; transp/target will be zeros.") + + # ---- transp (Combined alpha → u8, CNN convention: 0=opaque) ---- + alpha = combined_rgba[..., 3] if combined_rgba is not None \ + else np.ones((height, width), dtype=np.float32) + transp_u8 = ((1.0 - np.clip(alpha, 0.0, 1.0)) * 255.0).astype(np.uint8) Image.fromarray(transp_u8, mode="L").save( os.path.join(output_dir, "transp.png") ) - # ---- target (Combined beauty pass → RGBA uint8, gamma-correct) ---- - try: - combined_rgba = get_pass_rgba(channels, "Combined") - # Convert linear → sRGB for display (RGB channels only) + # ---- target (Combined beauty → RGBA uint8, gamma-correct) ---- + if combined_rgba is not None: c_rgb = np.power(np.clip(combined_rgba[..., :3], 0, 1), 1.0 / 2.2) c_alpha = combined_rgba[..., 3:4] - target_lin = np.concatenate([c_rgb, c_alpha], axis=-1) - target_u8 = (np.clip(target_lin, 0, 1) * 255.0).astype(np.uint8) - except KeyError: - print(" WARNING: Combined pass not found; target will be zeros.") + target_u8 = (np.clip(np.concatenate([c_rgb, c_alpha], axis=-1), 0, 1) * 255.0).astype(np.uint8) + else: target_u8 = np.zeros((height, width, 4), dtype=np.uint8) Image.fromarray(target_u8, mode="RGBA").save( os.path.join(output_dir, "target.png") -- cgit v1.2.3