From dcb9b0a508ff9fae5d44387d07837edbde1d8cfb Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Sun, 19 Jul 2026 09:27:34 -0400 Subject: [PATCH] feat: add png-exr-alpha float PNG false-unpremul witness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI float scratch buffers routinely die on Image.save() to PNG — Blender writes RGBA16 and unpremultiplies as if associated-alpha, clamping dark straight values at low alpha to white (closed-form err 0.98). Ship the contract beside OpenEXR and byte-PNG controls so it cannot quietly drift. Co-authored-by: Cursor --- .cursor-plugin/plugin.json | 1 + .github/workflows/blender-smoke.yml | 12 + AGENTS.md | 4 +- CLAUDE.md | 6 +- README.md | 24 +- ROADMAP.md | 4 +- docs/gallery/assets/png-exr-alpha-hero.webp | Bin 0 -> 14196 bytes docs/gallery/index.html | 12 + docs/gallery/png-exr-alpha/index.html | 780 ++++++++++++++++++++ examples/gallery.json | 13 + examples/png-exr-alpha/README.md | 71 ++ examples/png-exr-alpha/png_exr_alpha.py | 532 +++++++++++++ examples/png-exr-alpha/preview.webp | Bin 0 -> 12934 bytes 13 files changed, 1446 insertions(+), 13 deletions(-) create mode 100644 docs/gallery/assets/png-exr-alpha-hero.webp create mode 100644 docs/gallery/png-exr-alpha/index.html create mode 100644 examples/png-exr-alpha/README.md create mode 100644 examples/png-exr-alpha/png_exr_alpha.py create mode 100644 examples/png-exr-alpha/preview.webp 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 0000000000000000000000000000000000000000..dc32d3c94bfffb460bf112ad62f54cd9979478ac GIT binary patch literal 14196 zcmY+qV{|6b7A^Y4R>yY7cE|46wr$(C(XrLBZQHiZj`Q8_bIyHty!ul$M$NtU+Iy~9 zYmTZ?ln@iM<^TZHM1|y4Y@N>8)7-;cS226X`yW{?%`5^qTyVdIf zN&t7CIQZg@2961R{q})gPjcD_`#^?X;MLB$Q^S|tO-o!4u2aj29Bj4zm|QmS$YbS; zK}=T=i2MZpC3vQB@cdGv+_R$qaHnJx?tB+ft&DB3Jg8cqiG@eL;hU4(X>wS$(RTTz zhHttwqOs6*%kDcm-v!EIi;;_)Xq*ED?81-XQ$zLKT;vI~;pL!&u)>c;dxJ9CM)tR1 zo0eP&jV`^xewfFesh2$etu_5tNMP&@6U@C?H+P>JCzw3a^;0|BHE(bKmq*JZwq$RZ z7x@g~Bnjy62Wn|W%&1dE(3*l;ZJgLkr9-qTZb}_ErgH_;fiFY1I|kIA;J$SO#NF)r zHnFJTrQ&1dOkER+zo&Fhqj0SX83x{>LA)nY=9~qBY{od7TCBR|FS8nisRr6{d)UW9 z9>a6oG*sFg`RR**P)tg76AIW+A$u8<6 zQCyPrDZuv$@UF&tGqZPDSM%EA{Rs2;6aM?ZyYz3%)JO>2*tF4#N}vxIg78iB-g7=j zS}N6WZMxCR@F~tQWd*^xo7zUgU+5W4KMn@HF|k;DopER8qkf+ZO_-k$QV~b?`uV&s zpAf?3XL60W!?ZXqyRf=7cTMw%_c;V{oDh)iw)Xvch(o#62Ze^l-1gRPj&<1e@r@o! zoXgSL6DJ_uo_Xq0Jy1tS(PZ&;*yn?b#_r*~BZL@+-^p@6*eAHB>VZU7N1XKy5aEF} z+9CV7OH7FfE+*hCJ?n+oh-o%*KFORDPi!}eO3F#-b5lbNkI! zn6{L{D6juNPDHX>@0Gw2+gOB&SV^aI{!`9S&Hsq~Z>@{qpRAKK{VQ_Z&X+(9gvx;Q zbsASF6OLNLyB5lSy4Vu`|Fio)LG+!p^QilXsIqnBWN-P@j-U)}Jq<1#Ik3KJ%ls9E zT*|=0{{sIKcCqsdf zQWpN@MbCG4d$(|$W_RGZ*x)ZGcp@Z%%|_eq3_x^#nji5b6;jQK^@>JbZQqz_IDJ606ETxkO6UdDW~1;>m_7*%Zig)@%B^ALI^A(&#WN;rpXVMjH`d;?nprh@!pup z5<71`%`M(vK%+9E@gI$SCAv%;iHzGQzkgT`yu{;fzE27bUj!aN+Wl80Ji&uLg(QC~ zU*~^Jw_dPej!^@0y4gTXrwTc?{C;5Lb5&)Q8A`(#3BFudG)8kv`AB9X)q7zA{rUd*LBaN`%FSTx?&LeXj|{Pi&0Bf~E-W63y_ zaSc<+k$xth0gKalI>HuigXRhL1*a!kktX8Y&`Ai8=UP)bYk6UgkwBR{=5n>ZY< zAnS`CJtY|9@aGsxoT=BA(T_$pN@>EO zM`Mo=Y(=0c4f&cwuY|8@0JciVxks~6UiW#0`S)QB)AGnrf<3u{{{yx456HiXxX~=y-(7xeh2Io+5#E%QaBs>9;J{e)wduH|P^0 zqY#)!`6iIyLH;Achm0%x&vm)1SC}Wyvmu2~RZ6&XU?aiQ=6i5p8wVt;Ad?dEX|nb~ z)6EfEIqSn7W^<7JjR2Z#;H{;k#Uo%$8V!pS#OqbIpt3@=9Nb86I#wn@({9+B{a&ER&S!C?<0t;O$G0gs<>-(8 zRWu>!cde%NZG{{r7AlyC%WS~jHVJrDAE<9KQP5H!B}_UNWW zfn75!uNV5Yl(-|uVI&nO>y-#rrb~=WeE7%uRq6wIgvv|E*I;cN$3*ICW9KO~oOnBS zNp?HkwE1D*`2Yw1S9l42P9@efiUbFI*0>LLRbb;B)@Ly=PpeEYGn_I%gTJ+P#*|Hh zbJzug_)|;2w^!8?LqxRmfwx$9Qz9Lc6iP3As Y!=cL;H!jMWv%Ax1L#*L98@aDJ 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 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 0000000000000000000000000000000000000000..7e448c93e62bdd05190673954edf348b1e64401d GIT binary patch literal 12934 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 aNanQ#C>q_Alv23!`*QAhx1<9A@c#hwiRJYG literal 0 HcmV?d00001