Image Pixels Testcard

examples/image-pixels-testcard/

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.

Rendered headless by the example itself. Select it to enlarge.

witnesses pixels is always flat RGBA (channels == 4 even with alpha=False), byte storage quantizes at exactly ≤ 0.5/255 and strictly > 0, stale-size bulk reads raise after scale() — and save() silently flips source to FILE and drops the buffer, so later pixels reads come from whatever sits on disk (proven with an imposter file).

tags images performance rendering

runs on 4.5 LTS · 5.1 · 5.2 LTS

blender --background --python examples/image-pixels-testcard/image_pixels_testcard.py --

falsify it Breaks the contract on purpose and must exit 13

blender --background --python examples/image-pixels-testcard/image_pixels_testcard.py -- --wrong-origin

A runnable example that writes a procedural broadcast test card — seven neon bars, a luminance ramp, a PLUGE row with a bottom-left origin marker, and the classic circle — into a bpy.data.images.new() datablock with one pixels.foreach_set() call (512 × 288 × 4 = 589,824 floats), then renders it on an emissive studio monitor.

What it witnesses: the bpy.types.Image pixel-buffer contract. Image.pixels is a flat, row-major, bottom-left-origin float buffer that is always RGBA: channels == 4 and len(pixels) == width × height × 4 even when the image is created with alpha=False, so an RGB-stride foreach_set raises TypeError instead of writing. A byte image (the default) stores 8 bits per channel — every written float round-trips with error ≤ 0.5/255 and strictly > 0 (an exact round-trip would mean storage is not 8-bit); float_buffer=True stores float32 and round-trips at ~1e-7. Image.scale() reallocates the buffer, so a foreach_get into a stale-size list raises TypeError rather than silently shearing rows.

The save() trap (found while authoring — identical on 4.5 LTS and 5.1): Image.save() on a GENERATED image silently flips source to 'FILE' and drops the in-memory buffer (has_data becomes False). Every later pixels read re-loads from whatever currently sits at filepath_raw. The check proves it by overwriting the file with a flat-gray imposter image after save() and reading the imposter's pixels back through the original datablock. save_render() writes the same PNG but is non-destructive: source stays 'GENERATED' and the buffer stays exact. Scripts that write pixels, save(), then keep computing on pixels are silently computing on a decoded PNG.

What each check catches on failure:

Version divergence: none — every contract above, including the save() trap, was probed and asserts identically on Blender 4.5.11 LTS and 5.1.2. The only 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 hazard worth knowing: a bmesh-built plane has no UV map, and bmesh.ops.create_grid(..., calc_uvs=True) silently creates none unless a UV layer already exists — without one, an Image Texture samples texel (0,0) for every fragment and the screen renders as one flat color. The render path creates the layer explicitly.

Run#

# Cheap correctness check (no render) — the CI check:
blender --background --python image_pixels_testcard.py --

# Falsifier: write the card top-down. Must exit 13 (PNG row order).
blender --background --python image_pixels_testcard.py -- --wrong-origin

# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
blender --background --python image_pixels_testcard.py -- --output card.png
blender --background --python image_pixels_testcard.py -- --output card.png --engine cycles

Exit codes#

Per-script sequential checks. 9 is a valid check code; there is no rule against it.

CodeMeaning
0Success
1Uncaught exception (FATAL wrapper)
2argparse / usage
3Pixel buffer is not always RGBA
4Byte round-trip error or no quantization
5Float-buffer round-trip failed
6scale() did not reallocate, or stale-size read succeeded
7save() source/buffer-drop contract drifted
8save_render() flipped source or disturbed the buffer
9Byte PNG save/reload error
10Framing gate violation on the --output path (gallery_framing)
12--output produced no file
13Saved PNG rows do not put pixel (0, 0) at the bottom-left (--wrong-origin lands here)

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 --wrong-origin (expects exit 13).

In the render, Closest interpolation keeps the pixel grid honest — the jagged circle edge is the 512 × 288 buffer itself, and the white marker in the PLUGE row sits at the bottom-left because that is where pixel (0, 0) lives. The monitor is staged as a broadcast reference display — slim beveled bezel over a stepped rear housing, a matte sun hood framing the picture, input keys and a teal power LED on the bottom bezel, a machined stand — on a walnut desk with drawer pedestals, on the dark studio stage from docs/VISUAL-STYLE.md (Standard view transform; warm key, cool fill and rim; a warm pool raking the back wall). The screen stays emissive and matte — specular off, emission strength 1.0 — so the card's values read exactly. The render path gates framing through examples/gallery_framing.py (exit 10) before writing; the desk is passed as stage, like the floor, because its crop at the frame bottom is the composition.

Source

examples/image-pixels-testcard/image_pixels_testcard.py View the script on GitHub →
A hooded broadcast reference monitor on a walnut desk showing a colour-bar test card with a black circle, a greyscale ramp and a PLUGE row.