Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/gallery/image-pixels-testcard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,23 +65,23 @@ <h1>Image Pixels Testcard</h1>
<p><strong>What it witnesses:</strong> the <code>bpy.types.Image</code> pixel-buffer contract. <code>Image.pixels</code> is a flat, row-major, bottom-left-origin float buffer that is <em>always</em> RGBA: <code>channels == 4</code> and <code>len(pixels) == width × height × 4</code> even when the image is created with <code>alpha=False</code>, so an RGB-stride <code>foreach_set</code> raises <code>TypeError</code> instead of writing. A byte image (the default) stores 8 bits per channel — every written float round-trips with error ≤ 0.5/255 <strong>and strictly &gt; 0</strong> (an exact round-trip would mean storage is not 8-bit); <code>float_buffer=True</code> stores float32 and round-trips at ~1e-7. <code>Image.scale()</code> reallocates the buffer, so a <code>foreach_get</code> into a stale-size list raises <code>TypeError</code> rather than silently shearing rows.</p>
<p><strong>The <code>save()</code> trap</strong> (found while authoring — identical on 4.5 LTS and 5.1): <code>Image.save()</code> on a <code>GENERATED</code> image silently flips <code>source</code> to <code>&#x27;FILE&#x27;</code> and drops the in-memory buffer (<code>has_data</code> becomes <code>False</code>). Every later <code>pixels</code> read re-loads from whatever currently sits at <code>filepath_raw</code>. The check proves it by overwriting the file with a flat-gray imposter image <em>after</em> <code>save()</code> and reading the imposter&#x27;s pixels back through the original datablock. <code>save_render()</code> writes the same PNG but is non-destructive: <code>source</code> stays <code>&#x27;GENERATED&#x27;</code> and the buffer stays exact. Scripts that write pixels, <code>save()</code>, then keep computing on <code>pixels</code> are silently computing on a decoded PNG.</p>
<p><strong>What each check catches on failure:</strong></p>
<ul><li><em>Buffer geometry</em> — an API change to per-image channel counts, or code assuming an RGB stride (falsified: a <code>W*H*3</code> write raises and the check exits 3).</li><li><em>Byte/float round-trip vs the closed-form card</em> — 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.</li><li><em>Quantization floor</em> — <code>byte_err &gt; 0</code> proves 8-bit storage really quantizes; byte and float images swapping behavior cannot hide.</li><li><em>Reallocation</em> — <code>scale()</code> no longer reallocating (stale-size read succeeding).</li><li><em>save() lifecycle</em> — the source-flip/buffer-drop behavior changing (falsified: substituting <code>save_render()</code> for <code>save()</code> exits 7 because <code>source</code> stays <code>GENERATED</code>).</li><li><em>Disk round-trip</em> — a byte sRGB image saved to PNG and reloaded must match at quantization tolerance.</li></ul>
<ul><li><em>Buffer geometry</em> — an API change to per-image channel counts, or code assuming an RGB stride (falsified: a <code>W*H*3</code> write raises and the check exits 3).</li><li><em>Byte/float round-trip</em> — a stride 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. A <code>foreach_set</code> → <code>foreach_get</code> round trip reads back the order it wrote, so it cannot see row order; that is the next check&#x27;s job.</li><li><em>Row order through a real PNG</em> — the byte image is saved with <code>Image.save()</code> and the file&#x27;s rows are decoded with <code>zlib</code> + <code>struct</code> alone, no <code>bpy</code>. PNG stores the top row first, so a bottom-left origin puts pixel (0, 0) — the origin marker — in the file&#x27;s <strong>last</strong> row, and every PNG row <code>k</code> must match card row <code>H-1-k</code> at quantization tolerance. Measured: the marker sits in PNG rows 247..287 of 0..287 and the rows match at 0.0019608 (tolerance 0.0019618). <code>--wrong-origin</code> writes the card top-down through Blender: the marker lands in PNG rows 0..40, the row error is 0.9216, and the check exits 13.</li><li><em>Quantization floor</em> — <code>byte_err &gt; 0</code> proves 8-bit storage really quantizes; byte and float images swapping behavior cannot hide.</li><li><em>Reallocation</em> — <code>scale()</code> no longer reallocating (stale-size read succeeding).</li><li><em>save() lifecycle</em> — the source-flip/buffer-drop behavior changing (falsified: substituting <code>save_render()</code> for <code>save()</code> exits 7 because <code>source</code> stays <code>GENERATED</code>).</li><li><em>Disk round-trip</em> — a byte sRGB image saved to PNG and reloaded must match at quantization tolerance.</li></ul>
<p><strong>Version divergence:</strong> none — every contract above, including the <code>save()</code> 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 (<code>BLENDER_EEVEE_NEXT</code> on 4.x, <code>BLENDER_EEVEE</code> on 5.x).</p>
<p><strong>Render hazard worth knowing:</strong> a bmesh-built plane has no UV map, and <code>bmesh.ops.create_grid(..., calc_uvs=True)</code> 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.</p>
<h2 id="run">Run<a class="anchor" href="#run" aria-label="Link to this section">#</a></h2>
<pre tabindex="0"><code># Cheap correctness check (no render) — the CI check:
blender --background --python image_pixels_testcard.py --

# Falsifier: write the card top-down. Must exit non-zero (byte round-trip).
# 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</code></pre>
<h2 id="exit-codes">Exit codes<a class="anchor" href="#exit-codes" aria-label="Link to this section">#</a></h2>
<p>Per-script sequential checks. <code>9</code> is a valid check code; there is no rule against it.</p>
<div class="table-wrap"><table><thead><tr><th scope="col">Code</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td>0</td><td>Success</td></tr><tr><td>1</td><td>Uncaught exception (FATAL wrapper)</td></tr><tr><td>2</td><td>argparse / usage</td></tr><tr><td>3</td><td>Pixel buffer is not always RGBA</td></tr><tr><td>4</td><td>Byte round-trip vs closed-form card (<code>--wrong-origin</code> lands here)</td></tr><tr><td>5</td><td>Float-buffer round-trip failed</td></tr><tr><td>6</td><td><code>scale()</code> did not reallocate, or stale-size read succeeded</td></tr><tr><td>7</td><td><code>save()</code> source/buffer-drop contract drifted</td></tr><tr><td>8</td><td><code>save_render()</code> flipped source or disturbed the buffer</td></tr><tr><td>9</td><td>Byte PNG save/reload error</td></tr><tr><td>10</td><td>Framing gate violation on the <code>--output</code> path (<code>gallery_framing</code>)</td></tr><tr><td>12</td><td><code>--output</code> produced no file</td></tr></tbody></table></div>
<p>The <code>blender-smoke</code> workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the <code>needs-5.1</code> PR label, or manual dispatch). Smoke does not pass <code>--output</code>. Its catalog falsifier is <code>--wrong-origin</code> (expects exit 4).</p>
<div class="table-wrap"><table><thead><tr><th scope="col">Code</th><th scope="col">Meaning</th></tr></thead><tbody><tr><td>0</td><td>Success</td></tr><tr><td>1</td><td>Uncaught exception (FATAL wrapper)</td></tr><tr><td>2</td><td>argparse / usage</td></tr><tr><td>3</td><td>Pixel buffer is not always RGBA</td></tr><tr><td>4</td><td>Byte round-trip error or no quantization</td></tr><tr><td>5</td><td>Float-buffer round-trip failed</td></tr><tr><td>6</td><td><code>scale()</code> did not reallocate, or stale-size read succeeded</td></tr><tr><td>7</td><td><code>save()</code> source/buffer-drop contract drifted</td></tr><tr><td>8</td><td><code>save_render()</code> flipped source or disturbed the buffer</td></tr><tr><td>9</td><td>Byte PNG save/reload error</td></tr><tr><td>10</td><td>Framing gate violation on the <code>--output</code> path (<code>gallery_framing</code>)</td></tr><tr><td>12</td><td><code>--output</code> produced no file</td></tr><tr><td>13</td><td>Saved PNG rows do not put pixel (0, 0) at the bottom-left (<code>--wrong-origin</code> lands here)</td></tr></tbody></table></div>
<p>The <code>blender-smoke</code> workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the <code>needs-5.1</code> PR label, or manual dispatch). Smoke does not pass <code>--output</code>. Its catalog falsifier is <code>--wrong-origin</code> (expects exit 13).</p>
<p>In the render, <code>Closest</code> 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 <code>docs/VISUAL-STYLE.md</code> (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&#x27;s values read exactly. The render path gates framing through <code>examples/gallery_framing.py</code> (exit 10) before writing; the desk is passed as stage, like the floor, because its crop at the frame bottom is the composition.</p>
</section>
<section class="detail-section src" id="source">
Expand Down
19 changes: 11 additions & 8 deletions examples/extension-package-lifecycle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@ Scaffolding matches [`eval-mesh-datablock-name`](../eval-mesh-datablock-name/)
**What failure each check would catch:**

- exit 3 — the template stopped validating or building, or the zip lost its manifest
- exit 4 — a package whose wheel does not exist passed the release gate
(`--validate-only` lands here: it gates on `validate` alone); also exit 4 if
`validate` starts rejecting the missing wheel, because the trap this example
names would then be gone
- exit 4 — `build` accepted the package whose manifest names a wheel it does
not ship, so nothing between `validate` and a release would catch it
(`--ship-wheel` lands here: it creates the named wheel, so `build` exits 0
and writes a zip, proving the rejection comes from the missing file); also
exit 4 if `validate` starts rejecting the package, because the trap this
example names would then be gone
- exit 5 — `server-generate` wrote no listing, or the listing does not name the package
- exit 6 — install, import, upgrade or remove failed, or `__package__` is not
`bl_ext.user_default.example_addon`
Expand All @@ -60,7 +62,8 @@ Scaffolding matches [`eval-mesh-datablock-name`](../eval-mesh-datablock-name/)
| user data after `remove` | deleted | deleted | deleted |
| `online_access` default / `--online-mode` | False / True | False / True | False / True |
| default exit | 0 | 0 | 0 |
| `--validate-only` / `--data-next-to-file` exit | 4 / 7 | 4 / 7 | 4 / 7 |
| `--ship-wheel` / `--data-next-to-file` exit | 4 / 7 | not re-run / 7 | 4 / 7 |
| validate / build, wheel shipped (`--ship-wheel`) | 0 / 0, zip written | not re-run | 0 / 0, zip written |

## API reference

Expand All @@ -73,7 +76,7 @@ Scaffolding matches [`eval-mesh-datablock-name`](../eval-mesh-datablock-name/)

```bash
blender --background --python extension_package_lifecycle.py --
blender --background --python extension_package_lifecycle.py -- --validate-only
blender --background --python extension_package_lifecycle.py -- --ship-wheel
blender --background --python extension_package_lifecycle.py -- --data-next-to-file
```

Expand All @@ -88,7 +91,7 @@ than most examples.
| 1 | Uncaught exception (FATAL wrapper) |
| 2 | argparse / usage |
| 3 | Template does not validate or build, or the zip lacks its manifest |
| 4 | Missing-wheel package passed the release gate (`--validate-only` lands here) |
| 4 | `build` accepted the missing-wheel package, or `validate` rejected it (`--ship-wheel` lands here) |
| 5 | `server-generate` listing missing or wrong |
| 6 | Install, import, upgrade or remove failed, or `__package__` wrong |
| 7 | User data inside the install tree, lost on upgrade, or kept after removal (`--data-next-to-file` lands here) |
Expand All @@ -97,4 +100,4 @@ than most examples.
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 passes no extra flags on the happy path. Its catalog falsifiers are
`--validate-only` (expects exit 4) and `--data-next-to-file` (expects exit 7).
`--ship-wheel` (expects exit 4) and `--data-next-to-file` (expects exit 7).
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
Check-only: no gallery still. The witnesses are exit codes and paths.

blender --background --python extension_package_lifecycle.py --
blender --background --python extension_package_lifecycle.py -- --validate-only
blender --background --python extension_package_lifecycle.py -- --ship-wheel
blender --background --python extension_package_lifecycle.py -- --data-next-to-file
"""
import argparse
Expand Down Expand Up @@ -92,24 +92,32 @@ def check_build(work):
return 0, dist


def check_wheel_trap(work, validate_only):
def check_wheel_trap(work, ship_wheel):
"""validate parses the manifest; only build opens the files it names."""
src = copy_template(os.path.join(work, "wheel_src"))
manifest = os.path.join(src, "blender_manifest.toml")
with open(manifest, "a", encoding="utf-8") as fh:
fh.write(f'\nwheels = ["{MISSING_WHEEL}"]\n')
if ship_wheel: # falsification: the named wheel exists, so build has nothing to reject
wheel = os.path.normpath(os.path.join(src, MISSING_WHEEL))
os.makedirs(os.path.dirname(wheel))
with zipfile.ZipFile(wheel, "w") as z:
z.writestr("not_shipped/__init__.py", "")
z.writestr("not_shipped-1.0.dist-info/METADATA",
"Metadata-Version: 2.1\nName: not_shipped\nVersion: 1.0\n")
out_dir = os.path.join(work, "wheel_dist")
os.makedirs(out_dir)
v_code, _ = blender(["--command", "extension", "validate", src])
b_code, b_out = blender(["--command", "extension", "build", "--source-dir", src, "--output-dir", out_dir])
shipped = os.listdir(out_dir)
print(f"missing wheel: validate exit {v_code}, build exit {b_code}, output {shipped}")
print(f"{'shipped' if ship_wheel else 'missing'} wheel: validate exit {v_code}, "
f"build exit {b_code}, output {shipped}")
if v_code != 0:
return fail("validate now rejects a missing wheel; the trap this example names is gone", 4)
gate_passed = v_code == 0 if validate_only else (b_code == 0 and bool(shipped))
if gate_passed:
return fail("a package whose wheel does not exist passed the release gate "
f"({'validate only' if validate_only else 'build'})", 4)
return fail("validate rejects the package; the validate-is-not-a-gate trap this "
"example names is gone", 4)
if b_code == 0 or shipped:
return fail(f"build accepted the package (exit {b_code}, output {shipped}); "
"only build can reject a wheel the manifest names but the source lacks", 4)
return 0


Expand Down Expand Up @@ -222,8 +230,8 @@ def check_online(user_dir):
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
p = argparse.ArgumentParser()
p.add_argument("--validate-only", action="store_true",
help="falsification: gate the release on validate alone")
p.add_argument("--ship-wheel", action="store_true",
help="falsification: create the wheel the manifest names, so build succeeds")
p.add_argument("--data-next-to-file", action="store_true",
help="falsification: store user data beside __file__")
args = p.parse_args(argv)
Expand All @@ -235,7 +243,7 @@ def main():
code, dist = check_build(work)
if code:
return code
code = check_wheel_trap(work, args.validate_only)
code = check_wheel_trap(work, args.ship_wheel)
if code:
return code
code = check_listing(dist)
Expand Down
22 changes: 16 additions & 6 deletions examples/image-pixels-testcard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,18 @@ decoded PNG.

- *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.
- *Byte/float round-trip* — a stride 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. A `foreach_set` → `foreach_get` round trip reads
back the order it wrote, so it cannot see row order; that is the next check's job.
- *Row order through a real PNG* — the byte image is saved with `Image.save()` and
the file's rows are decoded with `zlib` + `struct` alone, no `bpy`. PNG stores the
top row first, so a bottom-left origin puts pixel (0, 0) — the origin marker — in
the file's **last** row, and every PNG row `k` must match card row `H-1-k` at
quantization tolerance. Measured: the marker sits in PNG rows 247..287 of 0..287
and the rows match at 0.0019608 (tolerance 0.0019618). `--wrong-origin` writes the
card top-down through Blender: the marker lands in PNG rows 0..40, the row error
is 0.9216, and the check exits 13.
- *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).
Expand All @@ -57,7 +66,7 @@ and the screen renders as one flat color. The render path creates the layer expl
# Cheap correctness check (no render) — the CI check:
blender --background --python image_pixels_testcard.py --

# Falsifier: write the card top-down. Must exit non-zero (byte round-trip).
# 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):
Expand All @@ -76,18 +85,19 @@ against it.
| 1 | Uncaught exception (FATAL wrapper) |
| 2 | argparse / usage |
| 3 | Pixel buffer is not always RGBA |
| 4 | Byte round-trip vs closed-form card (`--wrong-origin` lands here) |
| 4 | Byte round-trip error or no quantization |
| 5 | Float-buffer round-trip failed |
| 6 | `scale()` did not reallocate, or stale-size read succeeded |
| 7 | `save()` source/buffer-drop contract drifted |
| 8 | `save_render()` flipped source or disturbed the buffer |
| 9 | Byte PNG save/reload error |
| 10 | Framing gate violation on the `--output` path (`gallery_framing`) |
| 12 | `--output` produced no file |
| 13 | Saved 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 4).
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
Expand Down
Loading
Loading