From 89e9f7921d7f3e55456a7e0294343e5877f169a6 Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Sat, 18 Jul 2026 19:22:27 -0400 Subject: [PATCH] feat: add image-pixels-testcard example (flat RGBA pixel buffer + save() lifecycle witness) The gallery had no coverage of bpy.types.Image, and bulk pixels.foreach_set was an explicit ROADMAP candidate. A procedural broadcast test card written with one foreach_set witnesses the contracts AI-generated image code gets wrong: the buffer is always flat RGBA (channels == 4 even with alpha=False), byte storage quantizes at exactly <= 0.5/255 and strictly > 0, scale() reallocates so stale-size bulk reads raise, and Image.save() silently flips source to FILE and drops the in-memory buffer -- later pixels reads come from whatever sits on disk, proven by overwriting the file with an imposter. save_render() is the non-destructive path. All contracts probed identical on 4.5.11 LTS and 5.1.2. Co-Authored-By: Claude Fable 5 --- .cursor-plugin/plugin.json | 1 + .github/workflows/blender-smoke.yml | 15 + AGENTS.md | 4 +- CLAUDE.md | 6 +- README.md | 23 +- ROADMAP.md | 4 +- .../assets/image-pixels-testcard-hero.webp | Bin 0 -> 18688 bytes docs/gallery/image-pixels-testcard/index.html | 618 ++++++++++++++++++ docs/gallery/index.html | 12 + examples/gallery.json | 13 + examples/image-pixels-testcard/README.md | 69 ++ .../image_pixels_testcard.py | 371 +++++++++++ examples/image-pixels-testcard/preview.webp | Bin 0 -> 15930 bytes 13 files changed, 1128 insertions(+), 8 deletions(-) create mode 100644 docs/gallery/assets/image-pixels-testcard-hero.webp create mode 100644 docs/gallery/image-pixels-testcard/index.html create mode 100644 examples/image-pixels-testcard/README.md create mode 100644 examples/image-pixels-testcard/image_pixels_testcard.py create mode 100644 examples/image-pixels-testcard/preview.webp diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 53fb662..d9506c4 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -70,6 +70,7 @@ "examples/gn-instance-grid", "examples/gn-sdf-remesh", "examples/grease-pencil-rosette", + "examples/image-pixels-testcard", "examples/parent-inverse-orrery", "examples/shader-node-group", "examples/shape-key-blend", diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index baa6f5b..bc00e7c 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -311,3 +311,18 @@ jobs: # at to_mesh_clear(). Exits non-zero on failure. xvfb-run -a "$BLENDER" --background \ --python examples/text-version-stamp/text_version_stamp.py -- + + - name: Shipped example - image pixels testcard (flat RGBA buffer + save lifecycle) + run: | + set -euo pipefail + # Check only (no render): a procedural broadcast test card written with + # one pixels.foreach_set; asserts the buffer is always flat RGBA + # (channels == 4 even with alpha=False, RGB-stride writes raise), the + # byte round-trip error is <= 0.5/255 and strictly > 0 (storage really + # is 8-bit) while float_buffer=True round-trips at float32 precision, + # scale() reallocates so stale-size bulk reads raise, and save() flips + # source to FILE and drops the buffer (pixels silently re-load from + # disk — proven with an imposter file) while save_render() preserves + # it. Exits non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/image-pixels-testcard/image_pixels_testcard.py -- diff --git a/AGENTS.md b/AGENTS.md index 8cad4c5..c842cdb 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. -- 19 examples under `examples//`: runnable scripts that assert a real +- 20 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 @@ -41,7 +41,7 @@ Blender-Developer-Tools/ rules/.mdc # 6 rule files templates// # 2 starter templates snippets/.py # 17 standalone Python snippets - examples// # 19 runnable smoke-gated examples (+ gallery.json) + examples// # 20 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 6c6ba99..c43034b 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, 19 total (+ gallery.json) +examples// - Runnable smoke-gated examples, 20 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 (19) +## Examples (20) 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 nineteen run headless on Blender 4.5 LTS and 5.1 in `blender-smoke.yml`; +`--output`. All twenty 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 ade00f2..23fcd71 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,14 @@

- 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  19 examples + 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  20 examples

--- ## Overview -This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 19 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 20 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. @@ -332,6 +332,25 @@ but planar, that body edits regenerate geometry, that `version_string` is not ba semver on LTS builds (`"4.5.11 LTS"`), and that a Mesh reference dies at `to_mesh_clear()`. + + + + +Image pixels testcard: a studio monitor showing a procedural broadcast test card — seven neon color bars behind the classic dark circle, a luminance ramp, and a PLUGE row with a white bottom-left origin marker — over a teal underglow on a dark studio floor + + + +### [image-pixels-testcard](examples/image-pixels-testcard/) + +A procedural broadcast test card written into `bpy.data.images.new()` with one +`pixels.foreach_set()` call. Asserts the buffer is always flat RGBA (`channels == 4` +even with `alpha=False`), that byte storage quantizes at exactly ≤ 0.5/255 and +strictly > 0 while `float_buffer=True` round-trips at float32 precision, that +`scale()` reallocates (stale-size reads raise), and the `save()` trap: `source` +silently flips to `FILE`, the buffer drops, and later `pixels` reads come from +whatever sits on disk — proven with an imposter file. `save_render()` is the +non-destructive path. + diff --git a/ROADMAP.md b/ROADMAP.md index 4e824df..a5007d4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,7 +97,9 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - Refresh the `slotted-actions-animation` skill against any 5.2 changes - Bump `blender_version_min` in the templates if 5.2 APIs are used - Additional snippets for asset library scripting, EXR baking, multi-file extensions -- Gallery coverage follow-ups from the GPv3 review: light-linking, bulk `pixels.foreach_set`, VSE sequences-to-strips witness (`grease-pencil-rosette`, `armature-bend`, and `text-version-stamp` shipped first) +- 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: `bmesh.ops.create_grid(calc_uvs=True)` silently creates no UV layer unless one already exists, and an Image Texture without UVs samples texel (0,0) everywhere (found while authoring `image-pixels-testcard`) +- 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 ## Future (uncommitted) diff --git a/docs/gallery/assets/image-pixels-testcard-hero.webp b/docs/gallery/assets/image-pixels-testcard-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..3f0b028d1dc9d63a2714b856b04e706cd6e46cc8 GIT binary patch literal 18688 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%0DFu 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/image-pixels-testcard/index.html b/docs/gallery/image-pixels-testcard/index.html new file mode 100644 index 0000000..a4f5ba7 --- /dev/null +++ b/docs/gallery/image-pixels-testcard/index.html @@ -0,0 +1,618 @@ + + + + + + image-pixels-testcard — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

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 — click to zoom.

+
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).
+
+
blender --background --python examples/image-pixels-testcard/image_pixels_testcard.py --
+ +
+
+

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:

+
  • *Buffer geometry* — an API change to per-image channel counts, or code assuming an RGB stride (falsified: a W*H*3 write raises and the check exits 3).
  • *Byte/float round-trip vs the closed-form card* — any stride, orientation, or packing bug in the bulk path; a one-pixel shift was deliberately introduced once and the check exited 4 with measured error 0.97 against tolerance 0.00196.
  • *Quantization floor* — byte_err > 0 proves 8-bit storage really quantizes; byte and float images swapping behavior cannot hide.
  • *Reallocation* — scale() no longer reallocating (stale-size read succeeding).
  • *save() lifecycle* — the source-flip/buffer-drop behavior changing (falsified: substituting save_render() for save() exits 7 because source stays GENERATED).
  • *Disk round-trip* — a byte sRGB image saved to PNG and reloaded must match at quantization tolerance.
+

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 --
+
+# 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
+

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. 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.

+
+
+

Source

+
+ examples/image-pixels-testcard/image_pixels_testcard.py + View on GitHub → +
+
"""A procedural broadcast test card written with one foreach_set — a runnable example.
+
+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 RGB-stride writes raise. A byte image (the default) stores
+8 bits per channel — every float written round-trips through quantization with
+max error exactly <= 0.5/255 and strictly > 0 — while `float_buffer=True` stores
+float32 and round-trips to ~1e-7. `Image.scale()` reallocates the buffer, so a
+`foreach_get` into a stale-size list raises TypeError instead of silently
+shearing rows.
+
+The save lifecycle is the trap (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 silently re-loads
+from whatever sits at that path — the check proves it by overwriting the file
+with a different image and reading the imposter's pixels back through the
+original datablock. `save_render()` writes the same PNG but leaves
+`source` == 'GENERATED' and the buffer intact and exact.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+    blender --background --python image_pixels_testcard.py --                 # check only
+    blender --background --python image_pixels_testcard.py -- --output t.png  # + render
+"""
+import bpy, sys, os, math, argparse, tempfile
+
+W, H = 512, 288
+BYTE_TOL = 0.5 / 255.0 + 1e-6   # 8-bit quantization: round(v*255)/255, worst case half a step
+FLOAT_TOL = 1e-6                # float32 storage of float64 Python values
+
+# Saturated neon take on the classic seven SMPTE bars.
+BARS = [
+    (0.92, 0.92, 0.92), (0.95, 0.82, 0.10), (0.10, 0.90, 0.85),
+    (0.15, 0.85, 0.25), (0.90, 0.15, 0.80), (0.95, 0.20, 0.15),
+    (0.15, 0.30, 0.95),
+]
+
+
+def pattern(x, y):
+    """Closed-form test-card color at pixel (x, y), origin bottom-left.
+
+    Values are deliberately off the 1/255 grid so byte quantization is
+    measurable: a byte image that round-trips these exactly is a failure.
+    """
+    u, v = x / (W - 1), y / (H - 1)
+    if v < 0.14:
+        # bottom row: castellated PLUGE-style blocks, plus a bright white
+        # origin marker in the bottom-left cell — if a consumer assumes a
+        # top-down origin, the marker visibly jumps to the top of the frame
+        cell = int(u * 9)
+        if cell == 0:
+            r = g = b = 0.92
+        else:
+            r = g = b = (0.04, 0.10)[cell % 2]
+    elif v < 0.30:
+        # luminance ramp: black -> white, shows 8-bit banding
+        r = g = b = u * 0.97
+    else:
+        r, g, b = BARS[min(int(u * len(BARS)), len(BARS) - 1)]
+        # center circle outline, the test-card signature
+        d = math.hypot((u - 0.5) * (W / H), v - 0.64)
+        if abs(d - 0.31) < 0.012:
+            r, g, b = 0.03, 0.03, 0.03
+    return r, g, b, 1.0
+
+
+def flat_pattern():
+    """The whole card as one flat RGBA buffer in pixel-buffer order:
+    row-major from the BOTTOM row up, 4 floats per pixel."""
+    buf = [0.0] * (W * H * 4)
+    i = 0
+    for y in range(H):
+        for x in range(W):
+            buf[i:i + 4] = pattern(x, y)
+            i += 4
+    return buf
+
+
+def fail(msg, code):
+    print(f"ERROR: {msg}", file=sys.stderr)
+    return code
+
+
+def check():
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    expected = flat_pattern()
+
+    # -- buffer geometry: always RGBA, even with alpha=False ----------------
+    img = bpy.data.images.new("TestCard", W, H, alpha=False)
+    if img.channels != 4 or len(img.pixels) != W * H * 4:
+        return fail(f"expected RGBA buffer ({W*H*4}), got channels={img.channels} "
+                    f"len={len(img.pixels)}", 3)
+    try:
+        img.pixels.foreach_set([0.0] * (W * H * 3))  # RGB stride
+        return fail("RGB-stride foreach_set was accepted — buffer is not always RGBA", 3)
+    except TypeError:
+        pass
+
+    # -- byte image: one bulk write, quantized round-trip --------------------
+    img.pixels.foreach_set(expected)
+    got = [0.0] * (W * H * 4)
+    img.pixels.foreach_get(got)
+    byte_err = max(abs(a - b) for a, b in zip(expected, got))
+    if byte_err > BYTE_TOL:
+        return fail(f"byte round-trip error {byte_err:.7f} > {BYTE_TOL:.7f} "
+                    f"(stride/orientation bug cannot hide at 512x288)", 4)
+    if byte_err <= 0.0:
+        return fail("byte image round-tripped exactly — storage is not 8-bit, "
+                    "the quantization contract is broken", 4)
+
+    # -- float image: same write, float32-exact round-trip -------------------
+    fimg = bpy.data.images.new("TestCardF", W, H, alpha=True, float_buffer=True)
+    if not fimg.is_float:
+        return fail("float_buffer=True did not produce a float image", 5)
+    fimg.pixels.foreach_set(expected)
+    fgot = [0.0] * (W * H * 4)
+    fimg.pixels.foreach_get(fgot)
+    float_err = max(abs(a - b) for a, b in zip(expected, fgot))
+    if float_err > FLOAT_TOL:
+        return fail(f"float round-trip error {float_err:.2e} > {FLOAT_TOL:.0e}", 5)
+
+    # -- scale() reallocates: stale-size bulk reads must raise ---------------
+    half = bpy.data.images.new("Half", W, H, alpha=True)
+    half.pixels.foreach_set(expected)
+    stale = [0.0] * (W * H * 4)
+    half.scale(W // 2, H // 2)
+    if len(half.pixels) != (W // 2) * (H // 2) * 4:
+        return fail(f"scale() did not reallocate: len={len(half.pixels)}", 6)
+    try:
+        half.pixels.foreach_get(stale)
+        return fail("foreach_get accepted a stale-size buffer after scale()", 6)
+    except TypeError:
+        pass
+
+    # -- the save() trap: source flips to FILE and pixels re-source from disk --
+    tmpdir = tempfile.mkdtemp()
+    trap = bpy.data.images.new("Trap", W, H, alpha=True, float_buffer=True)
+    trap.pixels.foreach_set(expected)
+    trap.filepath_raw = os.path.join(tmpdir, "trap.png")
+    trap.file_format = 'PNG'
+    trap.save()
+    if trap.source != 'FILE' or trap.has_data:
+        return fail(f"save() contract changed: source={trap.source} "
+                    f"has_data={trap.has_data} (expected FILE / False)", 7)
+    # overwrite the file with a mid-gray card BEFORE touching trap.pixels;
+    # if the buffer were still in memory the read below would return the card
+    imposter = bpy.data.images.new("Imposter", W, H, alpha=True)
+    gray = 154.0 / 255.0  # exactly representable in 8-bit, no quantization noise
+    imposter.pixels.foreach_set([gray] * (W * H * 4))
+    imposter.filepath_raw = trap.filepath_raw
+    imposter.file_format = 'PNG'
+    imposter.save()
+    tgot = [0.0] * (W * H * 4)
+    trap.pixels.foreach_get(tgot)  # silently re-loaded from the PNG on disk
+    trap_err = max(abs(v - gray) for v in tgot)
+    if trap_err > BYTE_TOL:
+        return fail(f"pixels did not re-source from disk after save() "
+                    f"(max dev from imposter gray {trap_err:.4f}) — the buffer-drop "
+                    "trap this example documents has vanished", 7)
+
+    # -- save_render() is the non-destructive path ----------------------------
+    keep = bpy.data.images.new("Keep", W, H, alpha=True, float_buffer=True)
+    keep.pixels.foreach_set(expected)
+    keep.save_render(os.path.join(tmpdir, "keep.png"))
+    if keep.source != 'GENERATED':
+        return fail(f"save_render() flipped source to {keep.source}", 8)
+    kgot = [0.0] * (W * H * 4)
+    keep.pixels.foreach_get(kgot)
+    keep_err = max(abs(a - b) for a, b in zip(expected, kgot))
+    if keep_err > FLOAT_TOL:
+        return fail(f"save_render() disturbed the buffer (err {keep_err:.2e})", 8)
+
+    # -- byte sRGB image: PNG save/reload round-trips at quantization --------
+    disk = bpy.data.images.new("Disk", W, H, alpha=True)
+    disk.pixels.foreach_set(expected)
+    disk.filepath_raw = os.path.join(tmpdir, "disk.png")
+    disk.file_format = 'PNG'
+    disk.save()
+    reloaded = bpy.data.images.load(disk.filepath_raw)
+    rgot = [0.0] * (W * H * 4)
+    reloaded.pixels.foreach_get(rgot)
+    disk_err = max(abs(a - b) for a, b in zip(expected, rgot))
+    if disk_err > BYTE_TOL:
+        return fail(f"byte PNG save/reload error {disk_err:.7f} > {BYTE_TOL:.7f}", 9)
+
+    print(f"byte round-trip max err {byte_err:.7f} (tol {BYTE_TOL:.7f}, must be > 0), "
+          f"float {float_err:.2e} (tol {FLOAT_TOL:.0e}), post-save() imposter read "
+          f"max dev {trap_err:.7f} (buffer really dropped), save_render() "
+          f"{keep_err:.2e}, byte PNG reload {disk_err:.7f}")
+    return 0
+
+
+def eevee_engine_id():
+    return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
+
+
+def render_still(path, engine):
+    import bmesh
+    scene = bpy.context.scene
+
+    # the card itself: a byte sRGB image, written with the one bulk call
+    card = bpy.data.images.new("TestCard", W, H, alpha=False)
+    card.pixels.foreach_set(flat_pattern())
+
+    # screen: emissive plane textured with the card (16:9, 3.2 wide)
+    sw, sh = 3.2, 1.8
+    screen_me = bpy.data.meshes.new("Screen")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=0.5)
+        # a UV layer must exist explicitly — without UVs every fragment
+        # samples texel (0,0) and the screen renders one flat color
+        uv = bm.loops.layers.uv.new("UVMap")
+        for face in bm.faces:
+            for loop in face.loops:
+                loop[uv].uv = (loop.vert.co.x + 0.5, loop.vert.co.y + 0.5)
+        bm.to_mesh(screen_me)
+    finally:
+        bm.free()
+    smat = bpy.data.materials.new("CardEmit")
+    smat.use_nodes = True
+    nodes, links = smat.node_tree.nodes, smat.node_tree.links
+    tex = nodes.new("ShaderNodeTexImage")
+    tex.image = card
+    tex.interpolation = 'Closest'   # keep the pixel grid honest up close
+    bsdf = nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = (0.0, 0.0, 0.0, 1.0)
+    bsdf.inputs["Roughness"].default_value = 0.4
+    links.new(tex.outputs["Color"], bsdf.inputs["Emission Color"])
+    bsdf.inputs["Emission Strength"].default_value = 1.35  # hotter washes the bars pastel
+    screen_me.materials.append(smat)
+    screen = bpy.data.objects.new("Screen", screen_me)
+    screen.scale = (sw, sh, 1.0)
+    screen.rotation_euler = (math.radians(90), 0.0, math.radians(-16))
+    screen.location = (0.0, 0.0, sh / 2 + 0.14)
+    scene.collection.objects.link(screen)
+
+    # bezel: a slightly larger dark slab just behind the screen
+    bezel_me = bpy.data.meshes.new("Bezel")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_cube(bm, size=1.0)
+        bm.to_mesh(bezel_me)
+    finally:
+        bm.free()
+    bmat = bpy.data.materials.new("Bezel")
+    bmat.use_nodes = True
+    bb = bmat.node_tree.nodes["Principled BSDF"]
+    bb.inputs["Base Color"].default_value = (0.02, 0.022, 0.025, 1.0)
+    bb.inputs["Roughness"].default_value = 0.28
+    bb.inputs["Metallic"].default_value = 0.6
+    bezel_me.materials.append(bmat)
+    bezel = bpy.data.objects.new("Bezel", bezel_me)
+    bezel.scale = (sw + 0.14, 0.09, sh + 0.14)
+    bezel.rotation_euler = (0.0, 0.0, math.radians(-16))
+    # offset along the yawed screen normal so the slab stays behind the plane
+    bezel.location = (0.052 * math.sin(math.radians(16)),
+                      0.052 * math.cos(math.radians(16)), sh / 2 + 0.14)
+    scene.collection.objects.link(bezel)
+
+    # plinth foot under the monitor
+    foot = bpy.data.objects.new("Foot", bezel_me.copy())
+    foot.data.materials.clear(); foot.data.materials.append(bmat)
+    foot.scale = (1.1, 0.5, 0.14)
+    foot.location = (0.0, 0.05, 0.07)
+    scene.collection.objects.link(foot)
+
+    # glossy dark floor + back wall
+    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.045, 0.05, 0.06, 1.0)
+    fb.inputs["Roughness"].default_value = 0.18
+    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, 9.0, 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.025, 0.03, 0.04, 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)
+
+    # teal underglow bar tucked beneath the monitor, spilling onto the floor
+    strip = bpy.data.objects.new("GlowStrip", bezel_me.copy())
+    gmat = bpy.data.materials.new("Glow")
+    gmat.use_nodes = True
+    gb = gmat.node_tree.nodes["Principled BSDF"]
+    gb.inputs["Emission Color"].default_value = (0.1, 0.9, 0.8, 1.0)
+    gb.inputs["Emission Strength"].default_value = 12.0
+    strip.data.materials.clear(); strip.data.materials.append(gmat)
+    strip.scale = (2.7, 0.045, 0.015)
+    strip.rotation_euler = (0.0, 0.0, math.radians(-16))
+    strip.location = (0.0, -0.10, 0.015)
+    scene.collection.objects.link(strip)
+
+    # the card is its own key light; cool fill and a warm rim shape the bezel
+    light("Fill", (-5.2, -3.0, 3.6), 80.0, 7.0, (0.7, 0.8, 1.0), (58, 0, -60))
+    light("Rim", (4.2, 2.8, 2.2), 260.0, 2.5, (1.0, 0.72, 0.45), (-70, 0, 125))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 44.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (-2.45, -5.7, 1.62)
+    cam.rotation_euler = (math.radians(84.5), 0.0, math.radians(-23.0))
+    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 = 32
+    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
+    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:
+        return code
+
+    if args.output:
+        if not render_still(os.path.abspath(args.output), args.engine):
+            print("ERROR: render produced no file", file=sys.stderr)
+            return 10
+        print(f"rendered still {args.output}")
+
+    print("image-pixels-testcard OK")
+    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/docs/gallery/index.html b/docs/gallery/index.html index 4a1b6c3..31db7c4 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -188,6 +188,7 @@

Examples Gallery

+ @@ -410,6 +411,17 @@

text-version-stamp

View example +
+ + image-pixels-testcard — The Image pixel-buffer contract — a procedural broadcast test card written into bpy + +
+

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.

+

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).

+ View example +
+