diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 53de354..23dd67a 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -72,6 +72,7 @@ "examples/grease-pencil-rosette", "examples/image-pixels-testcard", "examples/parent-inverse-orrery", + "examples/png-exr-alpha", "examples/shader-node-group", "examples/shape-key-blend", "examples/swatch-grid", diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index 9278523..b08011f 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -338,3 +338,15 @@ jobs: # non-zero on failure. xvfb-run -a "$BLENDER" --background \ --python examples/uv-layer-grid/uv_layer_grid.py -- + + - name: Shipped example - png vs exr alpha (float PNG false unpremul) + run: | + set -euo pipefail + # Check only (no render): float_buffer=True Image.save() to PNG writes + # RGBA16 and unpremultiplies as if associated-alpha, so straight-authored + # dark values at low alpha clamp to white (closed-form err 0.98 at + # RGB 0.02 / a=1/255); OpenEXR round-trips within 1e-5; byte→PNG is + # straight RGBA8; EXR color_mode=RGB drops alpha to opaque. Exits + # non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/png-exr-alpha/png_exr_alpha.py -- diff --git a/AGENTS.md b/AGENTS.md index 2f37d62..68e09b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ The content base (counts are CI-enforced against README.md and the manifest): - 2 templates: `extension-addon-template` for Extensions Platform add-ons, and `headless-batch-script-template` for unattended batch jobs. - 17 snippets covering canonical patterns. -- 21 examples under `examples//`: runnable scripts that assert a real +- 22 examples under `examples//`: runnable scripts that assert a real API contract with deterministic checks, exit non-zero on failure, and optionally render a still via `--output`. Each is executed headless on Blender 4.5 LTS and 5.1 by `blender-smoke.yml`; its render ships in the @@ -42,7 +42,7 @@ Blender-Developer-Tools/ rules/.mdc # 6 rule files templates// # 2 starter templates snippets/.py # 17 standalone Python snippets - examples// # 21 runnable smoke-gated examples (+ gallery.json) + examples// # 22 runnable smoke-gated examples (+ gallery.json) scripts/build_gallery.py # generates docs/gallery/ (stdlib only) scripts/site/ # vendored landing-page build (build_site.py + template) docs/gallery/ # committed generated gallery pages + hero assets diff --git a/CLAUDE.md b/CLAUDE.md index a849aa0..254c7fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ skills//SKILL.md - AI workflow definitions, 12 total rules/.mdc - Anti-pattern rules, 6 total templates// - Starter projects, 2 total snippets/.py - Standalone code patterns, 17 total -examples// - Runnable smoke-gated examples, 21 total (+ gallery.json) +examples// - Runnable smoke-gated examples, 22 total (+ gallery.json) scripts/build_gallery.py - Regenerates docs/gallery/ from gallery.json (stdlib only) scripts/site/ - Vendored landing-page build (Jinja2) docs/gallery/ - Committed generated gallery pages + hero renders @@ -80,11 +80,11 @@ v0.1.0: canonical object creation and deletion, depsgraph evaluated mesh, bmesh v0.2.0: Principled BSDF material, driver-with-custom-function via `driver_namespace`, application handler registration, shader node group with cross-version `interface` API, `foreach_get` bulk vertex read, version-branch skeleton, and USD export with `evaluation_mode='RENDER'`. -## Examples (21) +## Examples (22) Runnable scripts at `examples//`, each asserting a real API contract with deterministic checks (exit non-zero on failure) and optionally rendering a still via -`--output`. All twenty-one run headless on Blender 4.5 LTS and 5.1 in `blender-smoke.yml`; +`--output`. All twenty-two run headless on Blender 4.5 LTS and 5.1 in `blender-smoke.yml`; their renders ship in the site gallery at `docs/gallery/`. `examples/gallery.json` is the gallery's source of truth. When authoring a new one, copy the anatomy of `examples/bmesh-gear/` (script structure, README shape, dark-studio render recipe) and diff --git a/README.md b/README.md index 3e3e4a4..449b575 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  21 examples + 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  22 examples

@@ -36,7 +36,7 @@ ## Overview -This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 21 runnable examples** for Blender Python development targeting Blender 5.1 (current stable) with Blender 4.5 LTS fallback support. +This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 22 runnable examples** for Blender Python development targeting Blender 5.1 (current stable) with Blender 4.5 LTS fallback support. The content is consumed by AI coding agents (Cursor, Claude Code, any MCP-capable client) when working on Blender add-ons, geometry nodes scripts, batch pipelines, or animation tooling. There is no build step. Edit the markdown and Python files directly. @@ -85,7 +85,7 @@ or expand a category below.

-Materials, shading & compositing — 5 examples +Materials, shading & compositing — 6 examples @@ -166,12 +166,22 @@ non-destructive path. -
+ + +PNG vs EXR alpha: two easel panels on a dark studio floor — left float→PNG false-unpremul clamps dark mid-tones to white at low alpha, right the EXR-clean authored straight buffer with surviving red and green primaries + + -
+### [png-exr-alpha](examples/png-exr-alpha/) -
-Mesh, curves & text — 7 examples +`float_buffer=True` images saved to PNG are written as RGBA16 and unpremultiplied +as if associated-alpha — straight-authored dark values at low alpha clamp to white +(closed-form error **0.98** at `(0.02, a=1/255)`). OpenEXR preserves float RGBA; +byte images stay straight 8-bit. Also witnesses `EXR color_mode='RGB'` dropping alpha. + + + + diff --git a/ROADMAP.md b/ROADMAP.md index 411cd19..46a2829 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -99,8 +99,10 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - Additional snippets for asset library scripting, EXR baking, multi-file extensions - Gallery coverage follow-ups from the GPv3 review: light-linking, VSE sequences-to-strips witness (`grease-pencil-rosette`, `armature-bend`, `text-version-stamp`, and `image-pixels-testcard` shipped first) - ~~UV-layer authoring witness~~ **SHIPPED** as `examples/uv-layer-grid/` — `create_grid(..., calc_uvs=True)` silent no-op without a pre-existing UV layer; closed-form UV fill + explicit assignment fallback; dual-panel render (flat texel (0,0) vs neon checker) -- Image save-format witness: float images saved to PNG with varying alpha suffer premultiply quantization (~0.98 worst-case round-trip error at 8-bit alpha extremes); EXR vs PNG storage contract +- ~~Image save-format witness~~ **SHIPPED** as `examples/png-exr-alpha/` — float→PNG is RGBA16 and false-unpremultiplies as if associated-alpha (closed-form err 0.98 at RGB 0.02 / a=1/255); OpenEXR preserves float RGBA; byte→PNG is straight RGBA8; `EXR color_mode='RGB'` drops alpha - Attribute domain witness: writing a POINT-domain color attribute and reading it as if it were CORNER (or vice versa) silently shears colors across shared verts — companion to `color-attribute-wheel` +- Light-linking collection witness: object light linking / receiver collections so a key illuminates only the hero (common AI miss when staging multi-light studios) +- VSE sequences-to-strips witness: building a short cut list from `sequences.new_effect` / movie strips and asserting frame ranges survive reload ## Future (uncommitted) diff --git a/docs/gallery/assets/png-exr-alpha-hero.webp b/docs/gallery/assets/png-exr-alpha-hero.webp new file mode 100644 index 0000000..dc32d3c Binary files /dev/null and b/docs/gallery/assets/png-exr-alpha-hero.webp differ diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 535c989..3ab4fdc 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -174,6 +174,7 @@

Examples Gallery

+
+ + png-exr-alpha — Float-image PNG save trap — float_buffer=True Image.save() writes RGBA16 and unpremultiplies as if associated-alpha, clamping straight-authored dark values at… + +
+

png-exr-alpha

+

Float-image PNG save trap — float_buffer=True Image.save() writes RGBA16 and unpremultiplies as if associated-alpha, clamping straight-authored dark values at low alpha to white (closed-form err 0.98 at RGB 0.02 / a=1/255).

+

witnesses float→PNG IHDR is RGBA16 and matches q16(min(1, c/q16(a))); OpenEXR round-trips within 1e-5; byte→PNG is straight RGBA8; EXR color_mode=RGB drops alpha to opaque.

+ View example +
+
uv-layer-grid — The UV-layer authoring hazard — bmesh.ops.create_grid(..., calc_uvs=True) is a silent no-op unless a UV layer already exists; without one an Image Texture… diff --git a/docs/gallery/png-exr-alpha/index.html b/docs/gallery/png-exr-alpha/index.html new file mode 100644 index 0000000..f60622e --- /dev/null +++ b/docs/gallery/png-exr-alpha/index.html @@ -0,0 +1,780 @@ + + + + + + png-exr-alpha — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

png-exr-alpha

+

Float-image PNG save trap — float_buffer=True Image.save() writes RGBA16 and unpremultiplies as if associated-alpha, clamping straight-authored dark values at low alpha to white (closed-form err 0.98 at RGB 0.02 / a=1/255).

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses float→PNG IHDR is RGBA16 and matches q16(min(1, c/q16(a))); OpenEXR round-trips within 1e-5; byte→PNG is straight RGBA8; EXR color_mode=RGB drops alpha to opaque.
+
+
blender --background --python examples/png-exr-alpha/png_exr_alpha.py --
+ +
+
+

A runnable example that witnesses the float-image PNG save trap: a float_buffer=True image saved via Image.save() to PNG is written as 16-bit RGBA and is unpremultiplied as if the buffer were associated-alpha before storage. The normal pixels API authors *straight* RGBA, so any channel where c > a blows past 1.0 and clamps to white — closed-form RGB error reaches 0.98 for authored (0.02, 0.02, 0.02) at alpha 1/255. The same buffer saved as OpenEXR round-trips at float precision. A byte image (float_buffer=False) writes 8-bit PNG with straight alpha and only pays ordinary quantization.

+

Found while probing the ROADMAP "image save-format" candidate against live Blender 5.1 — the hazard is not classic 8-bit premul quantization (float PNG is 16-bit); it is the false associated-alpha unpremultiply on a straight buffer. Documented here so the contract cannot quietly drift.

+

What it witnesses:

+

1. Float → PNG false unpremulImage.save() on a Non-Color float image writes RGBA16 (IHDR bit_depth=16, color_type=6). Reloaded pixels match the closed form q16(min(1, c / q16(a))) within 2/65535. Max RGB error vs authored on the probe palette is >= 0.90 (measured 0.98). 2. Float → OpenEXR fidelity — same authored buffer round-trips within 1e-5 (measured ~3e-8). 3. Byte → PNG straight alphafloat_buffer=False writes RGBA8; pixels match independent per-channel q8 within 0.5/255. The stress cell at (0.02, a=1/255) stays near 0.02, not clamped white. 4. EXR color_mode='RGB' drops alphasave_render with color_mode='RGB' reloads opaque alpha (≈ 1.0) even when authored alpha was 1/255.

+

What each check catches on failure:

+
  • *RGBA16 IHDR* — float PNG bit-depth or color-type change (falsified expectation: 8-bit would exit 4).
  • *Error floor* — float PNG path stops destroying straight mid-tones / dark values at low alpha (exit 5).
  • *Closed-form residual* — encoding no longer matches false-unpremul+clamp (falsified once by swapping in the byte straight-alpha model; exited 6 with residual 0.9803922).
  • *EXR fidelity* — OpenEXR float RGBA round-trip regressing (exit 7).
  • *Byte straight path* — byte images starting to false-unpremul, or bit-depth flipping to 16 (exits 9–11).
  • *RGB EXR alpha drop* — color_mode='RGB' preserving authored alpha (exit 12).
+

Version divergence: none probed on the save/reload contracts above — they assert on Blender 5.1.1 locally. Blender 4.5 LTS is exercised by the blender-smoke CI job (4.5 is not installed on this authoring host; do not treat a 4.4 run as a 4.5 substitute). The only version gate in the file is the EEVEE engine id for the optional render (BLENDER_EEVEE_NEXT on 4.x, BLENDER_EEVEE on 5.x).

+

Render: two easel panels. Left bakes the closed-form PNG mangling (dark rows flash to white at low-alpha columns). Right shows the authored straight buffer (EXR-clean). If the contract failed, both panels would read the same.

+

Run

+
# Cheap correctness check (no render) — the CI check:
+blender --background --python png_exr_alpha.py --
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python png_exr_alpha.py -- --output alpha.png
+blender --background --python png_exr_alpha.py -- --output alpha.png --engine cycles
+

It exits non-zero on failure and prints every measured error and tolerance on success, so CI logs carry the numbers. The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.

+
+
+

Source

+
+ examples/png-exr-alpha/png_exr_alpha.py + View on GitHub → +
+
"""PNG vs EXR alpha round-trip — a runnable example.
+
+Witnesses that a `float_buffer=True` image saved via `Image.save()` to PNG
+is written as **16-bit RGBA** and, critically, is **unpremultiplied as if
+the buffer were associated-alpha** before storage. The normal `pixels` API
+authors *straight* RGBA, so any channel where `c > a` blows up past 1.0 and
+clamps to white on save — closed-form error reaches ~0.98 for authored
+RGB≈0.02 at alpha 1/255. The same buffer saved as OpenEXR round-trips at
+float precision. A byte image (`float_buffer=False`) writes **8-bit** PNG
+with straight alpha and only pays ordinary quantization.
+
+AI-generated Blender code commonly trusts `Image.save()` to PNG for float
+RGBA scratch buffers (masks, ID mattes, AOVs). Pass --output to also render
+a dual-easel still: left is the PNG-mangled reload, right is the EXR-clean
+reload.
+
+    blender --background --python png_exr_alpha.py --
+    blender --background --python png_exr_alpha.py -- --output alpha.png
+"""
+import bpy
+import bmesh
+import sys
+import os
+import math
+import argparse
+import tempfile
+import struct
+
+# Columns = alphas, rows = authored RGB triples chosen to stress the trap.
+ALPHAS = [i / 255.0 for i in (1, 2, 8, 16, 32, 64, 128, 255)]
+COLORS = [
+    (0.02, 0.02, 0.02),  # ~0.98 err at a=1/255 after false unpremul+clamp
+    (0.5, 0.5, 0.5),     # clamps to white for every a < 0.5
+    (0.25, 0.25, 0.25),
+    (1.0, 1.0, 1.0),     # survives (already at the clamp ceiling)
+    (1.0, 0.0, 0.0),     # chromatic primary — survives visually
+    (0.0, 1.0, 0.0),
+    (0.0, 0.0, 1.0),
+    (0.0, 1.0, 1.0),
+]
+W = len(ALPHAS)
+H = len(COLORS)
+
+PNG_ERR_FLOOR = 0.90          # float→PNG must hurt at least this much
+PNG_MODEL_TOL = 2.0 / 65535.0 + 1e-6
+BYTE_TOL = 0.5 / 255.0 + 1e-6
+EXR_TOL = 1e-5
+
+
+def fail(msg, code):
+    print(f"ERROR: {msg}", file=sys.stderr)
+    return code
+
+
+def q16(x):
+    """Quantize to 16-bit the way Blender's float→PNG writer does (half up)."""
+    return int(max(0.0, min(1.0, x)) * 65535.0 + 0.5) / 65535.0
+
+
+def q8(x):
+    return int(max(0.0, min(1.0, x)) * 255.0 + 0.5) / 255.0
+
+
+def expected_float_png_rgba(r, g, b, a):
+    """Closed-form float→PNG→reload (Non-Color).
+
+    Blender writes float images as 16-bit PNG and unpremultiplies each
+    channel as if the buffer were associated-alpha (`c/a`, clamp to 1)
+    before storage. Straight-authored pixels with `c > a` therefore reload
+    as clamped white (or a boosted mid-tone).
+    """
+    a_q = q16(a)
+    if a_q <= 0.0:
+        return (0.0, 0.0, 0.0, 0.0)
+    return (q16(min(1.0, r / a_q)), q16(min(1.0, g / a_q)),
+            q16(min(1.0, b / a_q)), a_q)
+
+
+def expected_byte_png_rgba(r, g, b, a):
+    """Byte images store straight 8-bit RGBA — no false unpremul."""
+    return (q8(r), q8(g), q8(b), q8(a))
+
+
+def fill_pattern(img):
+    buf = [0.0] * (W * H * 4)
+    for y, rgb in enumerate(COLORS):
+        r, g, b = rgb
+        for x, a in enumerate(ALPHAS):
+            i = (y * W + x) * 4
+            buf[i : i + 4] = [r, g, b, a]
+    img.pixels.foreach_set(buf)
+    return buf
+
+
+def max_channel_err(orig, got, channels=4):
+    err = 0.0
+    for i in range(0, len(orig), 4):
+        for c in range(channels):
+            err = max(err, abs(orig[i + c] - got[i + c]))
+    return err
+
+
+def max_model_err(got, expect_fn):
+    err = 0.0
+    worst = None
+    for y, rgb in enumerate(COLORS):
+        r, g, b = rgb
+        for x, a in enumerate(ALPHAS):
+            exp = expect_fn(r, g, b, a)
+            i = (y * W + x) * 4
+            for c in range(4):
+                e = abs(got[i + c] - exp[c])
+                if e > err:
+                    err = e
+                    worst = (x, y, a, rgb, exp, got[i : i + 4], e)
+    return err, worst
+
+
+def new_float_image(name):
+    img = bpy.data.images.new(name, W, H, alpha=True, float_buffer=True)
+    img.colorspace_settings.name = "Non-Color"
+    return img
+
+
+def new_byte_image(name):
+    img = bpy.data.images.new(name, W, H, alpha=True, float_buffer=False)
+    img.colorspace_settings.name = "Non-Color"
+    return img
+
+
+def save_and_reload(img, fmt, ext):
+    """Persist via Image.save() and return pixels from a fresh load.
+
+    save() (not save_render) avoids scene color-management baking into the
+    file. Non-Color keeps the buffer linear so the unpremul trap is isolated.
+    """
+    td = tempfile.mkdtemp(prefix="png_exr_alpha_")
+    path = os.path.join(td, f"probe.{ext}")
+    img.filepath_raw = path
+    img.file_format = fmt
+    img.save()
+    loaded = bpy.data.images.load(path)
+    loaded.colorspace_settings.name = "Non-Color"
+    got = [0.0] * (W * H * 4)
+    loaded.pixels.foreach_get(got)
+    return got, path
+
+
+def png_bit_depth(path):
+    """Return (bit_depth, color_type) from a PNG IHDR."""
+    with open(path, "rb") as f:
+        data = f.read()
+    i = 8
+    while i + 8 <= len(data):
+        length = struct.unpack(">I", data[i:i + 4])[0]
+        ctype = data[i + 4:i + 8]
+        chunk = data[i + 8:i + 8 + length]
+        if ctype == b"IHDR":
+            _w, _h, bit, color, _comp, _filt, _inter = struct.unpack(
+                ">IIBBBBB", chunk,
+            )
+            return bit, color
+        i = i + 12 + length
+    return None, None
+
+
+def check():
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+
+    # --- Closed-form worst case on this palette (independent of Blender) ---
+    model_worst = 0.0
+    for rgb in COLORS:
+        for a in ALPHAS:
+            exp = expected_float_png_rgba(*rgb, a)
+            model_worst = max(
+                model_worst,
+                abs(exp[0] - rgb[0]),
+                abs(exp[1] - rgb[1]),
+                abs(exp[2] - rgb[2]),
+            )
+    if model_worst < PNG_ERR_FLOOR:
+        return fail(
+            f"closed-form palette worst {model_worst:.7f} < floor "
+            f"{PNG_ERR_FLOOR} — probe colors no longer stress false unpremul",
+            2,
+        )
+    print(
+        f"closed-form float→PNG worst RGB err {model_worst:.7f} "
+        f"(floor {PNG_ERR_FLOOR})"
+    )
+
+    # --- 1. float_buffer → PNG: 16-bit + false associated unpremul ---------
+    img_f = new_float_image("FloatSrc")
+    if not img_f.is_float:
+        return fail("float_buffer=True image reports is_float=False", 3)
+    orig = fill_pattern(img_f)
+    png_got, png_path = save_and_reload(img_f, "PNG", "png")
+
+    bit, color = png_bit_depth(png_path)
+    if bit != 16 or color != 6:
+        return fail(
+            f"float→PNG IHDR bit/color = {bit}/{color}, expected 16/6 (RGBA16) "
+            f"— bit-depth contract changed",
+            4,
+        )
+    print(f"float→PNG IHDR bit_depth={bit} color_type={color} (RGBA16)")
+
+    png_vs_orig = max_channel_err(orig, png_got, channels=3)
+    if png_vs_orig < PNG_ERR_FLOOR:
+        return fail(
+            f"float→PNG max RGB err {png_vs_orig:.7f} < floor {PNG_ERR_FLOOR} "
+            f"— false-unpremul damage missing (falsify: expecting a "
+            f"straight-alpha float PNG path)",
+            5,
+        )
+
+    model_err, worst = max_model_err(png_got, expected_float_png_rgba)
+    if model_err > PNG_MODEL_TOL:
+        return fail(
+            f"float→PNG disagrees with closed-form false-unpremul model by "
+            f"{model_err:.7f} (tol {PNG_MODEL_TOL:.7f}); worst={worst}",
+            6,
+        )
+    print(
+        f"float→PNG max RGB err vs authored {png_vs_orig:.7f} "
+        f"(must be >= {PNG_ERR_FLOOR}); model residual {model_err:.7f} "
+        f"(tol {PNG_MODEL_TOL:.7f})"
+    )
+
+    # --- 2. Same float buffer → OpenEXR: float-precision round-trip --------
+    img_f2 = new_float_image("FloatExrSrc")
+    fill_pattern(img_f2)
+    scene = bpy.context.scene
+    scene.render.image_settings.file_format = "OPEN_EXR"
+    scene.render.image_settings.color_mode = "RGBA"
+    try:
+        scene.render.image_settings.color_depth = "32"
+    except TypeError:
+        pass
+
+    td = tempfile.mkdtemp(prefix="png_exr_alpha_exr_")
+    exr_path = os.path.join(td, "probe.exr")
+    img_f2.filepath_raw = exr_path
+    img_f2.file_format = "OPEN_EXR"
+    img_f2.save()
+    exr_loaded = bpy.data.images.load(exr_path)
+    exr_loaded.colorspace_settings.name = "Non-Color"
+    exr_got = [0.0] * (W * H * 4)
+    exr_loaded.pixels.foreach_get(exr_got)
+
+    exr_err = max_channel_err(orig, exr_got, channels=4)
+    if exr_err > EXR_TOL:
+        return fail(
+            f"float→EXR max err {exr_err:.7e} > tol {EXR_TOL:.7e} "
+            f"— EXR no longer preserves float RGBA",
+            7,
+        )
+    print(f"float→EXR max err {exr_err:.7e} (tol {EXR_TOL:.7e})")
+
+    # --- 3. byte image → PNG: 8-bit straight alpha -------------------------
+    img_b = new_byte_image("ByteSrc")
+    if img_b.is_float:
+        return fail("float_buffer=False image reports is_float=True", 8)
+    fill_pattern(img_b)
+    byte_got, byte_path = save_and_reload(img_b, "PNG", "png")
+    bit_b, color_b = png_bit_depth(byte_path)
+    if bit_b != 8 or color_b != 6:
+        return fail(
+            f"byte→PNG IHDR bit/color = {bit_b}/{color_b}, expected 8/6 "
+            f"(RGBA8) — byte bit-depth contract changed",
+            9,
+        )
+    byte_model_err, byte_worst = max_model_err(byte_got, expected_byte_png_rgba)
+    if byte_model_err > BYTE_TOL:
+        return fail(
+            f"byte→PNG disagrees with straight-alpha 8-bit model by "
+            f"{byte_model_err:.7f} (tol {BYTE_TOL:.7f}); worst={byte_worst}",
+            10,
+        )
+    # Stress cell: authored (0.02,0.02,0.02,a=1/255). False unpremul → ~1.0;
+    # byte straight path must stay near 0.02.
+    stress = byte_got[0:4]
+    if stress[0] > 0.1:
+        return fail(
+            f"byte→PNG stress cell RGB=({stress[0]:.5f},{stress[1]:.5f},"
+            f"{stress[2]:.5f}) looks false-unpremul-mangled (expected ~0.02) "
+            f"— byte/float storage contract flipped",
+            11,
+        )
+    print(
+        f"byte→PNG IHDR bit_depth={bit_b}; model residual {byte_model_err:.7f} "
+        f"(tol {BYTE_TOL:.7f}); stress cell stays near 0.02 "
+        f"({stress[0]:.5f}), not clamped white"
+    )
+
+    # --- 4. OPEN_EXR color_mode='RGB' drops alpha --------------------------
+    img_f3 = new_float_image("FloatExrRgb")
+    fill_pattern(img_f3)
+    scene.render.image_settings.file_format = "OPEN_EXR"
+    scene.render.image_settings.color_mode = "RGB"
+    td3 = tempfile.mkdtemp(prefix="png_exr_alpha_rgb_")
+    rgb_path = os.path.join(td3, "probe_rgb.exr")
+    img_f3.save_render(filepath=rgb_path, scene=scene)
+    rgb_loaded = bpy.data.images.load(rgb_path)
+    rgb_loaded.colorspace_settings.name = "Non-Color"
+    rgb_got = [0.0] * (W * H * 4)
+    rgb_loaded.pixels.foreach_get(rgb_got)
+    a00 = rgb_got[3]
+    if abs(a00 - ALPHAS[0]) < 1e-3:
+        return fail(
+            f"EXR color_mode=RGB still preserved alpha={a00:.7f}"
+            f"the alpha-drop trap is gone (or save_render ignores color_mode)",
+            12,
+        )
+    if abs(a00 - 1.0) > 1e-3:
+        return fail(
+            f"EXR color_mode=RGB alpha at (0,0) is {a00:.7f}, expected ~1.0 "
+            f"(alpha channel dropped / filled opaque)",
+            12,
+        )
+    print(
+        f"EXR color_mode=RGB drops alpha (a00={a00:.7f} ≈ 1.0; authored was "
+        f"{ALPHAS[0]:.7f})"
+    )
+
+    print("png-exr-alpha: OK")
+    return 0
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def specular_off(bsdf):
+    if "Specular IOR Level" in bsdf.inputs:
+        bsdf.inputs["Specular IOR Level"].default_value = 0.0
+    elif "Specular" in bsdf.inputs:
+        bsdf.inputs["Specular"].default_value = 0.0
+
+
+def make_gallery_card(name, mangled):
+    """Visual card of the false-unpremul contract for the dual-easel still.
+
+    Columns = rising alpha; top rows = dark mid-tones that PNG clamps to
+    white; lower rows = primaries that survive. Left panel bakes the
+    closed-form mangling; right panel shows authored straight RGBA.
+    """
+    gw, gh = 256, 192
+    img = bpy.data.images.new(name, gw, gh, alpha=True, float_buffer=True)
+    img.colorspace_settings.name = "Non-Color"
+    px = [0.0] * (gw * gh * 4)
+    n_cols = 6
+    row_colors = [
+        (0.02, 0.02, 0.02),  # false-unpremul → white for every a < 1
+        (0.5, 0.5, 0.5),     # → white for every a < 0.5
+        (0.25, 0.25, 0.25),
+        (1.0, 0.0, 0.0),     # pure primaries survive (c <= 1 already at ceiling)
+        (0.0, 1.0, 0.0),
+        (0.2, 0.35, 1.0),    # blue-leaning; G/B still clamp at low a → wash
+    ]
+    alphas = [1 / 255, 8 / 255, 32 / 255, 64 / 255, 128 / 255, 1.0]
+    col_w = gw // n_cols
+    row_h = gh // len(row_colors)
+    for y in range(gh):
+        ri = min(y // row_h, len(row_colors) - 1)
+        rgb = row_colors[ri]
+        for x in range(gw):
+            ci = min(x // col_w, n_cols - 1)
+            a = alphas[ci]
+            if mangled:
+                r, g, b, _a = expected_float_png_rgba(*rgb, a)
+            else:
+                r, g, b = rgb
+            i = (y * gw + x) * 4
+            px[i : i + 4] = [r, g, b, 1.0]
+    img.pixels.foreach_set(px)
+    return img
+
+
+def easel_plane(name, image, loc, rot_z_deg):
+    me = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        bm.loops.layers.uv.new("UVMap")
+        bmesh.ops.create_grid(
+            bm, x_segments=1, y_segments=1, size=1.0, calc_uvs=True,
+        )
+        uv = bm.loops.layers.uv.active
+        for face in bm.faces:
+            for loop in face.loops:
+                u, v = loop[uv].uv
+                loop[uv].uv = (u, 1.0 - v)
+        bm.to_mesh(me)
+    finally:
+        bm.free()
+
+    mat = bpy.data.materials.new(name + "Mat")
+    mat.use_nodes = True
+    nt = mat.node_tree
+    nodes, links = nt.nodes, nt.links
+    bsdf = nodes["Principled BSDF"]
+    tex = nodes.new("ShaderNodeTexImage")
+    tex.image = image
+    tex.interpolation = "Closest"
+    links.new(tex.outputs["Color"], bsdf.inputs["Emission Color"])
+    bsdf.inputs["Base Color"].default_value = (0.0, 0.0, 0.0, 1.0)
+    bsdf.inputs["Roughness"].default_value = 1.0
+    specular_off(bsdf)
+    bsdf.inputs["Emission Strength"].default_value = 0.95
+    me.materials.append(mat)
+
+    obj = bpy.data.objects.new(name, me)
+    obj.location = loc
+    obj.rotation_euler = (math.radians(62), 0.0, math.radians(rot_z_deg))
+    obj.scale = (1.15, 1.15, 1.15)
+    bpy.context.collection.objects.link(obj)
+    return obj
+
+
+def render_still(path, engine):
+    scene = bpy.context.scene
+
+    png_card = make_gallery_card("PngMangled", mangled=True)
+    exr_card = make_gallery_card("ExrClean", mangled=False)
+
+    easel_plane("PngPanel", png_card, (-1.22, 0.0, 1.18), 10)
+    easel_plane("ExrPanel", exr_card, (1.22, 0.0, 1.18), -10)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Studio")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+    fb.inputs["Roughness"].default_value = 0.7
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 6.8, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.02, 0.021, 0.025, 1.0,
+    )
+    scene.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy = energy
+        ld.size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    light("Key", (-3.8, -4.8, 5.8), 360.0, 4.5, (1.0, 0.96, 0.9), (52, 0, -32))
+    light("Fill", (4.8, -2.8, 1.6), 70.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52))
+    light("Rim", (0.2, 4.0, 2.8), 220.0, 3.2, (0.6, 0.78, 1.0), (-62, 0, 178))
+    light("Wedge", (0.5, 5.2, 2.8), 520.0, 7.0, (1.0, 0.72, 0.42), (-78, 0, 180))
+
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 1.1)
+    scene.collection.objects.link(aim)
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 45.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (0.0, -5.4, 2.2)
+    con = cam.constraints.new("TRACK_TO")
+    con.target = aim
+    con.track_axis = "TRACK_NEGATIVE_Z"
+    con.up_axis = "UP_Y"
+    scene.collection.objects.link(cam)
+    scene.camera = cam
+
+    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        scene.cycles.samples = 64
+    else:
+        try:
+            scene.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    scene.render.resolution_x = 1280
+    scene.render.resolution_y = 720
+    scene.render.image_settings.file_format = "PNG"
+    scene.render.filepath = path
+    # AgX desaturates the card toward pastel — Standard keeps the clamp visible.
+    scene.view_settings.view_transform = "Standard"
+    bpy.ops.render.render(write_still=True)
+    return os.path.exists(path) and os.path.getsize(path) > 0
+
+
+def main():
+    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+    p = argparse.ArgumentParser()
+    p.add_argument("--output", default=None, help="optional: render a still PNG here")
+    p.add_argument(
+        "--engine", default="eevee", choices=("eevee", "cycles"),
+        help="render engine for --output (cycles for GPU-less hosts)",
+    )
+    args = p.parse_args(argv)
+
+    code = check()
+    if code != 0:
+        return code
+    if args.output:
+        bpy.ops.wm.read_factory_settings(use_empty=True)
+        if not render_still(os.path.abspath(args.output), args.engine):
+            return fail(f"render produced no file at {args.output}", 13)
+        print(f"wrote {args.output}")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except Exception as e:
+        import traceback
+        traceback.print_exc()
+        print(f"FATAL: {e}", file=sys.stderr)
+        sys.exit(1)
+
+
+
+ +
+
+ generated from examples/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/examples/gallery.json b/examples/gallery.json index 5e0fa7c..7cee43b 100644 --- a/examples/gallery.json +++ b/examples/gallery.json @@ -247,6 +247,19 @@ "rendering" ] }, + { + "name": "png-exr-alpha", + "dir": "examples/png-exr-alpha", + "teaches": "Float-image PNG save trap \u2014 float_buffer=True Image.save() writes RGBA16 and unpremultiplies as if associated-alpha, clamping straight-authored dark values at low alpha to white (closed-form err 0.98 at RGB 0.02 / a=1/255).", + "witnessesFix": "float\u2192PNG IHDR is RGBA16 and matches q16(min(1, c/q16(a))); OpenEXR round-trips within 1e-5; byte\u2192PNG is straight RGBA8; EXR color_mode=RGB drops alpha to opaque.", + "hero": "docs/gallery/assets/png-exr-alpha-hero.webp", + "preview": "examples/png-exr-alpha/preview.webp", + "tags": [ + "images", + "rendering", + "alpha" + ] + }, { "name": "uv-layer-grid", "dir": "examples/uv-layer-grid", diff --git a/examples/png-exr-alpha/README.md b/examples/png-exr-alpha/README.md new file mode 100644 index 0000000..380e546 --- /dev/null +++ b/examples/png-exr-alpha/README.md @@ -0,0 +1,71 @@ +# PNG vs EXR Alpha + +A runnable example that witnesses the float-image PNG save trap: a +`float_buffer=True` image saved via `Image.save()` to PNG is written as +**16-bit RGBA** and is **unpremultiplied as if the buffer were +associated-alpha** before storage. The normal `pixels` API authors *straight* +RGBA, so any channel where `c > a` blows past 1.0 and clamps to white — +closed-form RGB error reaches **0.98** for authored `(0.02, 0.02, 0.02)` at +alpha `1/255`. The same buffer saved as OpenEXR round-trips at float +precision. A byte image (`float_buffer=False`) writes **8-bit** PNG with +straight alpha and only pays ordinary quantization. + +Found while probing the ROADMAP "image save-format" candidate against live +Blender 5.1 — the hazard is not classic 8-bit premul quantization (float PNG +is 16-bit); it is the false associated-alpha unpremultiply on a straight +buffer. Documented here so the contract cannot quietly drift. + +**What it witnesses:** + +1. **Float → PNG false unpremul** — `Image.save()` on a Non-Color float image + writes RGBA16 (`IHDR` bit_depth=16, color_type=6). Reloaded pixels match + the closed form `q16(min(1, c / q16(a)))` within `2/65535`. Max RGB error + vs authored on the probe palette is `>= 0.90` (measured **0.98**). +2. **Float → OpenEXR fidelity** — same authored buffer round-trips within + `1e-5` (measured ~`3e-8`). +3. **Byte → PNG straight alpha** — `float_buffer=False` writes RGBA8; pixels + match independent per-channel `q8` within `0.5/255`. The stress cell at + `(0.02, a=1/255)` stays near 0.02, not clamped white. +4. **EXR `color_mode='RGB'` drops alpha** — `save_render` with + `color_mode='RGB'` reloads opaque alpha (`≈ 1.0`) even when authored + alpha was `1/255`. + +**What each check catches on failure:** + +- *RGBA16 IHDR* — float PNG bit-depth or color-type change (falsified + expectation: 8-bit would exit 4). +- *Error floor* — float PNG path stops destroying straight mid-tones / dark + values at low alpha (exit 5). +- *Closed-form residual* — encoding no longer matches false-unpremul+clamp + (falsified once by swapping in the byte straight-alpha model; exited **6** + with residual **0.9803922**). +- *EXR fidelity* — OpenEXR float RGBA round-trip regressing (exit 7). +- *Byte straight path* — byte images starting to false-unpremul, or bit-depth + flipping to 16 (exits 9–11). +- *RGB EXR alpha drop* — `color_mode='RGB'` preserving authored alpha (exit 12). + +**Version divergence:** none probed on the save/reload contracts above — they +assert on Blender 5.1.1 locally. Blender 4.5 LTS is exercised by the +`blender-smoke` CI job (4.5 is not installed on this authoring host; do not +treat a 4.4 run as a 4.5 substitute). The only version gate in the file is +the EEVEE engine id for the optional render (`BLENDER_EEVEE_NEXT` on 4.x, +`BLENDER_EEVEE` on 5.x). + +**Render:** two easel panels. Left bakes the closed-form PNG mangling (dark +rows flash to white at low-alpha columns). Right shows the authored straight +buffer (EXR-clean). If the contract failed, both panels would read the same. + +## Run + +```bash +# Cheap correctness check (no render) — the CI check: +blender --background --python png_exr_alpha.py -- + +# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts): +blender --background --python png_exr_alpha.py -- --output alpha.png +blender --background --python png_exr_alpha.py -- --output alpha.png --engine cycles +``` + +It exits non-zero on failure and prints every measured error and tolerance on +success, so CI logs carry the numbers. The `blender-smoke` workflow runs the +check on Blender 4.5 LTS and 5.1. diff --git a/examples/png-exr-alpha/png_exr_alpha.py b/examples/png-exr-alpha/png_exr_alpha.py new file mode 100644 index 0000000..6aec540 --- /dev/null +++ b/examples/png-exr-alpha/png_exr_alpha.py @@ -0,0 +1,532 @@ +"""PNG vs EXR alpha round-trip — a runnable example. + +Witnesses that a `float_buffer=True` image saved via `Image.save()` to PNG +is written as **16-bit RGBA** and, critically, is **unpremultiplied as if +the buffer were associated-alpha** before storage. The normal `pixels` API +authors *straight* RGBA, so any channel where `c > a` blows up past 1.0 and +clamps to white on save — closed-form error reaches ~0.98 for authored +RGB≈0.02 at alpha 1/255. The same buffer saved as OpenEXR round-trips at +float precision. A byte image (`float_buffer=False`) writes **8-bit** PNG +with straight alpha and only pays ordinary quantization. + +AI-generated Blender code commonly trusts `Image.save()` to PNG for float +RGBA scratch buffers (masks, ID mattes, AOVs). Pass --output to also render +a dual-easel still: left is the PNG-mangled reload, right is the EXR-clean +reload. + + blender --background --python png_exr_alpha.py -- + blender --background --python png_exr_alpha.py -- --output alpha.png +""" +import bpy +import bmesh +import sys +import os +import math +import argparse +import tempfile +import struct + +# Columns = alphas, rows = authored RGB triples chosen to stress the trap. +ALPHAS = [i / 255.0 for i in (1, 2, 8, 16, 32, 64, 128, 255)] +COLORS = [ + (0.02, 0.02, 0.02), # ~0.98 err at a=1/255 after false unpremul+clamp + (0.5, 0.5, 0.5), # clamps to white for every a < 0.5 + (0.25, 0.25, 0.25), + (1.0, 1.0, 1.0), # survives (already at the clamp ceiling) + (1.0, 0.0, 0.0), # chromatic primary — survives visually + (0.0, 1.0, 0.0), + (0.0, 0.0, 1.0), + (0.0, 1.0, 1.0), +] +W = len(ALPHAS) +H = len(COLORS) + +PNG_ERR_FLOOR = 0.90 # float→PNG must hurt at least this much +PNG_MODEL_TOL = 2.0 / 65535.0 + 1e-6 +BYTE_TOL = 0.5 / 255.0 + 1e-6 +EXR_TOL = 1e-5 + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def q16(x): + """Quantize to 16-bit the way Blender's float→PNG writer does (half up).""" + return int(max(0.0, min(1.0, x)) * 65535.0 + 0.5) / 65535.0 + + +def q8(x): + return int(max(0.0, min(1.0, x)) * 255.0 + 0.5) / 255.0 + + +def expected_float_png_rgba(r, g, b, a): + """Closed-form float→PNG→reload (Non-Color). + + Blender writes float images as 16-bit PNG and unpremultiplies each + channel as if the buffer were associated-alpha (`c/a`, clamp to 1) + before storage. Straight-authored pixels with `c > a` therefore reload + as clamped white (or a boosted mid-tone). + """ + a_q = q16(a) + if a_q <= 0.0: + return (0.0, 0.0, 0.0, 0.0) + return (q16(min(1.0, r / a_q)), q16(min(1.0, g / a_q)), + q16(min(1.0, b / a_q)), a_q) + + +def expected_byte_png_rgba(r, g, b, a): + """Byte images store straight 8-bit RGBA — no false unpremul.""" + return (q8(r), q8(g), q8(b), q8(a)) + + +def fill_pattern(img): + buf = [0.0] * (W * H * 4) + for y, rgb in enumerate(COLORS): + r, g, b = rgb + for x, a in enumerate(ALPHAS): + i = (y * W + x) * 4 + buf[i : i + 4] = [r, g, b, a] + img.pixels.foreach_set(buf) + return buf + + +def max_channel_err(orig, got, channels=4): + err = 0.0 + for i in range(0, len(orig), 4): + for c in range(channels): + err = max(err, abs(orig[i + c] - got[i + c])) + return err + + +def max_model_err(got, expect_fn): + err = 0.0 + worst = None + for y, rgb in enumerate(COLORS): + r, g, b = rgb + for x, a in enumerate(ALPHAS): + exp = expect_fn(r, g, b, a) + i = (y * W + x) * 4 + for c in range(4): + e = abs(got[i + c] - exp[c]) + if e > err: + err = e + worst = (x, y, a, rgb, exp, got[i : i + 4], e) + return err, worst + + +def new_float_image(name): + img = bpy.data.images.new(name, W, H, alpha=True, float_buffer=True) + img.colorspace_settings.name = "Non-Color" + return img + + +def new_byte_image(name): + img = bpy.data.images.new(name, W, H, alpha=True, float_buffer=False) + img.colorspace_settings.name = "Non-Color" + return img + + +def save_and_reload(img, fmt, ext): + """Persist via Image.save() and return pixels from a fresh load. + + save() (not save_render) avoids scene color-management baking into the + file. Non-Color keeps the buffer linear so the unpremul trap is isolated. + """ + td = tempfile.mkdtemp(prefix="png_exr_alpha_") + path = os.path.join(td, f"probe.{ext}") + img.filepath_raw = path + img.file_format = fmt + img.save() + loaded = bpy.data.images.load(path) + loaded.colorspace_settings.name = "Non-Color" + got = [0.0] * (W * H * 4) + loaded.pixels.foreach_get(got) + return got, path + + +def png_bit_depth(path): + """Return (bit_depth, color_type) from a PNG IHDR.""" + with open(path, "rb") as f: + data = f.read() + i = 8 + while i + 8 <= len(data): + length = struct.unpack(">I", data[i:i + 4])[0] + ctype = data[i + 4:i + 8] + chunk = data[i + 8:i + 8 + length] + if ctype == b"IHDR": + _w, _h, bit, color, _comp, _filt, _inter = struct.unpack( + ">IIBBBBB", chunk, + ) + return bit, color + i = i + 12 + length + return None, None + + +def check(): + bpy.ops.wm.read_factory_settings(use_empty=True) + + # --- Closed-form worst case on this palette (independent of Blender) --- + model_worst = 0.0 + for rgb in COLORS: + for a in ALPHAS: + exp = expected_float_png_rgba(*rgb, a) + model_worst = max( + model_worst, + abs(exp[0] - rgb[0]), + abs(exp[1] - rgb[1]), + abs(exp[2] - rgb[2]), + ) + if model_worst < PNG_ERR_FLOOR: + return fail( + f"closed-form palette worst {model_worst:.7f} < floor " + f"{PNG_ERR_FLOOR} — probe colors no longer stress false unpremul", + 2, + ) + print( + f"closed-form float→PNG worst RGB err {model_worst:.7f} " + f"(floor {PNG_ERR_FLOOR})" + ) + + # --- 1. float_buffer → PNG: 16-bit + false associated unpremul --------- + img_f = new_float_image("FloatSrc") + if not img_f.is_float: + return fail("float_buffer=True image reports is_float=False", 3) + orig = fill_pattern(img_f) + png_got, png_path = save_and_reload(img_f, "PNG", "png") + + bit, color = png_bit_depth(png_path) + if bit != 16 or color != 6: + return fail( + f"float→PNG IHDR bit/color = {bit}/{color}, expected 16/6 (RGBA16) " + f"— bit-depth contract changed", + 4, + ) + print(f"float→PNG IHDR bit_depth={bit} color_type={color} (RGBA16)") + + png_vs_orig = max_channel_err(orig, png_got, channels=3) + if png_vs_orig < PNG_ERR_FLOOR: + return fail( + f"float→PNG max RGB err {png_vs_orig:.7f} < floor {PNG_ERR_FLOOR} " + f"— false-unpremul damage missing (falsify: expecting a " + f"straight-alpha float PNG path)", + 5, + ) + + model_err, worst = max_model_err(png_got, expected_float_png_rgba) + if model_err > PNG_MODEL_TOL: + return fail( + f"float→PNG disagrees with closed-form false-unpremul model by " + f"{model_err:.7f} (tol {PNG_MODEL_TOL:.7f}); worst={worst}", + 6, + ) + print( + f"float→PNG max RGB err vs authored {png_vs_orig:.7f} " + f"(must be >= {PNG_ERR_FLOOR}); model residual {model_err:.7f} " + f"(tol {PNG_MODEL_TOL:.7f})" + ) + + # --- 2. Same float buffer → OpenEXR: float-precision round-trip -------- + img_f2 = new_float_image("FloatExrSrc") + fill_pattern(img_f2) + scene = bpy.context.scene + scene.render.image_settings.file_format = "OPEN_EXR" + scene.render.image_settings.color_mode = "RGBA" + try: + scene.render.image_settings.color_depth = "32" + except TypeError: + pass + + td = tempfile.mkdtemp(prefix="png_exr_alpha_exr_") + exr_path = os.path.join(td, "probe.exr") + img_f2.filepath_raw = exr_path + img_f2.file_format = "OPEN_EXR" + img_f2.save() + exr_loaded = bpy.data.images.load(exr_path) + exr_loaded.colorspace_settings.name = "Non-Color" + exr_got = [0.0] * (W * H * 4) + exr_loaded.pixels.foreach_get(exr_got) + + exr_err = max_channel_err(orig, exr_got, channels=4) + if exr_err > EXR_TOL: + return fail( + f"float→EXR max err {exr_err:.7e} > tol {EXR_TOL:.7e} " + f"— EXR no longer preserves float RGBA", + 7, + ) + print(f"float→EXR max err {exr_err:.7e} (tol {EXR_TOL:.7e})") + + # --- 3. byte image → PNG: 8-bit straight alpha ------------------------- + img_b = new_byte_image("ByteSrc") + if img_b.is_float: + return fail("float_buffer=False image reports is_float=True", 8) + fill_pattern(img_b) + byte_got, byte_path = save_and_reload(img_b, "PNG", "png") + bit_b, color_b = png_bit_depth(byte_path) + if bit_b != 8 or color_b != 6: + return fail( + f"byte→PNG IHDR bit/color = {bit_b}/{color_b}, expected 8/6 " + f"(RGBA8) — byte bit-depth contract changed", + 9, + ) + byte_model_err, byte_worst = max_model_err(byte_got, expected_byte_png_rgba) + if byte_model_err > BYTE_TOL: + return fail( + f"byte→PNG disagrees with straight-alpha 8-bit model by " + f"{byte_model_err:.7f} (tol {BYTE_TOL:.7f}); worst={byte_worst}", + 10, + ) + # Stress cell: authored (0.02,0.02,0.02,a=1/255). False unpremul → ~1.0; + # byte straight path must stay near 0.02. + stress = byte_got[0:4] + if stress[0] > 0.1: + return fail( + f"byte→PNG stress cell RGB=({stress[0]:.5f},{stress[1]:.5f}," + f"{stress[2]:.5f}) looks false-unpremul-mangled (expected ~0.02) " + f"— byte/float storage contract flipped", + 11, + ) + print( + f"byte→PNG IHDR bit_depth={bit_b}; model residual {byte_model_err:.7f} " + f"(tol {BYTE_TOL:.7f}); stress cell stays near 0.02 " + f"({stress[0]:.5f}), not clamped white" + ) + + # --- 4. OPEN_EXR color_mode='RGB' drops alpha -------------------------- + img_f3 = new_float_image("FloatExrRgb") + fill_pattern(img_f3) + scene.render.image_settings.file_format = "OPEN_EXR" + scene.render.image_settings.color_mode = "RGB" + td3 = tempfile.mkdtemp(prefix="png_exr_alpha_rgb_") + rgb_path = os.path.join(td3, "probe_rgb.exr") + img_f3.save_render(filepath=rgb_path, scene=scene) + rgb_loaded = bpy.data.images.load(rgb_path) + rgb_loaded.colorspace_settings.name = "Non-Color" + rgb_got = [0.0] * (W * H * 4) + rgb_loaded.pixels.foreach_get(rgb_got) + a00 = rgb_got[3] + if abs(a00 - ALPHAS[0]) < 1e-3: + return fail( + f"EXR color_mode=RGB still preserved alpha={a00:.7f} — " + f"the alpha-drop trap is gone (or save_render ignores color_mode)", + 12, + ) + if abs(a00 - 1.0) > 1e-3: + return fail( + f"EXR color_mode=RGB alpha at (0,0) is {a00:.7f}, expected ~1.0 " + f"(alpha channel dropped / filled opaque)", + 12, + ) + print( + f"EXR color_mode=RGB drops alpha (a00={a00:.7f} ≈ 1.0; authored was " + f"{ALPHAS[0]:.7f})" + ) + + print("png-exr-alpha: OK") + return 0 + + +def eevee_engine_id(): + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def specular_off(bsdf): + if "Specular IOR Level" in bsdf.inputs: + bsdf.inputs["Specular IOR Level"].default_value = 0.0 + elif "Specular" in bsdf.inputs: + bsdf.inputs["Specular"].default_value = 0.0 + + +def make_gallery_card(name, mangled): + """Visual card of the false-unpremul contract for the dual-easel still. + + Columns = rising alpha; top rows = dark mid-tones that PNG clamps to + white; lower rows = primaries that survive. Left panel bakes the + closed-form mangling; right panel shows authored straight RGBA. + """ + gw, gh = 256, 192 + img = bpy.data.images.new(name, gw, gh, alpha=True, float_buffer=True) + img.colorspace_settings.name = "Non-Color" + px = [0.0] * (gw * gh * 4) + n_cols = 6 + row_colors = [ + (0.02, 0.02, 0.02), # false-unpremul → white for every a < 1 + (0.5, 0.5, 0.5), # → white for every a < 0.5 + (0.25, 0.25, 0.25), + (1.0, 0.0, 0.0), # pure primaries survive (c <= 1 already at ceiling) + (0.0, 1.0, 0.0), + (0.2, 0.35, 1.0), # blue-leaning; G/B still clamp at low a → wash + ] + alphas = [1 / 255, 8 / 255, 32 / 255, 64 / 255, 128 / 255, 1.0] + col_w = gw // n_cols + row_h = gh // len(row_colors) + for y in range(gh): + ri = min(y // row_h, len(row_colors) - 1) + rgb = row_colors[ri] + for x in range(gw): + ci = min(x // col_w, n_cols - 1) + a = alphas[ci] + if mangled: + r, g, b, _a = expected_float_png_rgba(*rgb, a) + else: + r, g, b = rgb + i = (y * gw + x) * 4 + px[i : i + 4] = [r, g, b, 1.0] + img.pixels.foreach_set(px) + return img + + +def easel_plane(name, image, loc, rot_z_deg): + me = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + bm.loops.layers.uv.new("UVMap") + bmesh.ops.create_grid( + bm, x_segments=1, y_segments=1, size=1.0, calc_uvs=True, + ) + uv = bm.loops.layers.uv.active + for face in bm.faces: + for loop in face.loops: + u, v = loop[uv].uv + loop[uv].uv = (u, 1.0 - v) + bm.to_mesh(me) + finally: + bm.free() + + mat = bpy.data.materials.new(name + "Mat") + mat.use_nodes = True + nt = mat.node_tree + nodes, links = nt.nodes, nt.links + bsdf = nodes["Principled BSDF"] + tex = nodes.new("ShaderNodeTexImage") + tex.image = image + tex.interpolation = "Closest" + links.new(tex.outputs["Color"], bsdf.inputs["Emission Color"]) + bsdf.inputs["Base Color"].default_value = (0.0, 0.0, 0.0, 1.0) + bsdf.inputs["Roughness"].default_value = 1.0 + specular_off(bsdf) + bsdf.inputs["Emission Strength"].default_value = 0.95 + me.materials.append(mat) + + obj = bpy.data.objects.new(name, me) + obj.location = loc + obj.rotation_euler = (math.radians(62), 0.0, math.radians(rot_z_deg)) + obj.scale = (1.15, 1.15, 1.15) + bpy.context.collection.objects.link(obj) + return obj + + +def render_still(path, engine): + scene = bpy.context.scene + + png_card = make_gallery_card("PngMangled", mangled=True) + exr_card = make_gallery_card("ExrClean", mangled=False) + + easel_plane("PngPanel", png_card, (-1.22, 0.0, 1.18), 10) + easel_plane("ExrPanel", exr_card, (1.22, 0.0, 1.18), -10) + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = bpy.data.materials.new("Studio") + fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 + floor_me.materials.append(fmat) + floor = bpy.data.objects.new("Floor", floor_me) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 6.8, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.02, 0.021, 0.025, 1.0, + ) + scene.world = world + + def light(name, loc, energy, size, col, rot): + ld = bpy.data.lights.new(name, "AREA") + ld.energy = energy + ld.size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + scene.collection.objects.link(ob) + + light("Key", (-3.8, -4.8, 5.8), 360.0, 4.5, (1.0, 0.96, 0.9), (52, 0, -32)) + light("Fill", (4.8, -2.8, 1.6), 70.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52)) + light("Rim", (0.2, 4.0, 2.8), 220.0, 3.2, (0.6, 0.78, 1.0), (-62, 0, 178)) + light("Wedge", (0.5, 5.2, 2.8), 520.0, 7.0, (1.0, 0.72, 0.42), (-78, 0, 180)) + + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 1.1) + scene.collection.objects.link(aim) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 45.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (0.0, -5.4, 2.2) + con = cam.constraints.new("TRACK_TO") + con.target = aim + con.track_axis = "TRACK_NEGATIVE_Z" + con.up_axis = "UP_Y" + scene.collection.objects.link(cam) + scene.camera = cam + + scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + scene.cycles.samples = 64 + else: + try: + scene.eevee.taa_render_samples = 64 + except AttributeError: + pass + scene.render.resolution_x = 1280 + scene.render.resolution_y = 720 + scene.render.image_settings.file_format = "PNG" + scene.render.filepath = path + # AgX desaturates the card toward pastel — Standard keeps the clamp visible. + scene.view_settings.view_transform = "Standard" + bpy.ops.render.render(write_still=True) + return os.path.exists(path) and os.path.getsize(path) > 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--output", default=None, help="optional: render a still PNG here") + p.add_argument( + "--engine", default="eevee", choices=("eevee", "cycles"), + help="render engine for --output (cycles for GPU-less hosts)", + ) + args = p.parse_args(argv) + + code = check() + if code != 0: + return code + if args.output: + bpy.ops.wm.read_factory_settings(use_empty=True) + if not render_still(os.path.abspath(args.output), args.engine): + return fail(f"render produced no file at {args.output}", 13) + print(f"wrote {args.output}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + import traceback + traceback.print_exc() + print(f"FATAL: {e}", file=sys.stderr) + sys.exit(1) diff --git a/examples/png-exr-alpha/preview.webp b/examples/png-exr-alpha/preview.webp new file mode 100644 index 0000000..7e448c9 Binary files /dev/null and b/examples/png-exr-alpha/preview.webp differ