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. Select it to enlarge.
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.
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:
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).
Float → OpenEXR fidelity — same authored buffer round-trips within 1e-5 (measured ~3e-8).
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.
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).
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 thin-bezel monitors on machined stands, sharing a walnut-topped console — the left screen bakes the closed-form PNG mangling (dark rows flash to white at low-alpha columns), the right screen shows the authored straight buffer (EXR-clean); brass desk plates in front read FLOAT → PNG and FLOAT → EXR. If the contract failed, both screens would read the same. The render path gates framing through examples/gallery_framing.py (exit 10) before writing the still.
Closed-form palette no longer stresses unpremul (checked first)
15
Byte→PNG disagrees with straight-alpha 8-bit model
16
Byte→PNG stress cell looks false-unpremul-mangled
The blender-smoke workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the needs-5.1 PR label, or manual dispatch). Smoke does not pass --output. Its catalog falsifier is --opaque-alpha (expects exit 5).
The Image pixel-buffer contract — a procedural broadcast test card written into bpy.data.images.new() with one pixels.foreach_set (589,824 floats), byte vs float_buffer storage, scale() reallocation, and the save() vs save_render() lifecycle.
Bloom where it actually lives — a compositor Glare (Fog Glow) node fed by Render Layers, wired via scene.compositing_node_group on 5.x and scene.node_tree on 4.x, with the Glare node's legacy properties vs 5.x menu sockets.