diff --git a/docs/gallery/assets/png-exr-alpha-hero.webp b/docs/gallery/assets/png-exr-alpha-hero.webp index dc32d3c..3dc1390 100644 Binary files a/docs/gallery/assets/png-exr-alpha-hero.webp and b/docs/gallery/assets/png-exr-alpha-hero.webp differ diff --git a/docs/gallery/contact-sheets/png-exr-alpha-restage.png b/docs/gallery/contact-sheets/png-exr-alpha-restage.png new file mode 100644 index 0000000..0c24189 Binary files /dev/null and b/docs/gallery/contact-sheets/png-exr-alpha-restage.png differ diff --git a/docs/gallery/contact-sheets/png-exr-alpha-restage.webp b/docs/gallery/contact-sheets/png-exr-alpha-restage.webp new file mode 100644 index 0000000..15fa3ed Binary files /dev/null and b/docs/gallery/contact-sheets/png-exr-alpha-restage.webp differ diff --git a/docs/gallery/png-exr-alpha/index.html b/docs/gallery/png-exr-alpha/index.html index f60622e..ccb3f46 100644 --- a/docs/gallery/png-exr-alpha/index.html +++ b/docs/gallery/png-exr-alpha/index.html @@ -552,8 +552,12 @@

Source

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. + + A dark bezel is painted into the texture so panel edges read as framed + objects in the studio still (render-only — checks use fill_pattern). """ gw, gh = 256, 192 + border = 10 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) @@ -567,25 +571,32 @@

Source

(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) + iw, ih = gw - 2 * border, gh - 2 * border + col_w = max(1, iw // n_cols) + row_h = max(1, ih // len(row_colors)) + bezel = (0.04, 0.042, 0.048) 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) + i = (y * gw + x) * 4 + if x < border or y < border or x >= gw - border or y >= gh - border: + px[i : i + 4] = [bezel[0], bezel[1], bezel[2], 1.0] + continue + xi, yi = x - border, y - border + ri = min(yi // row_h, len(row_colors) - 1) + ci = min(xi // col_w, n_cols - 1) + rgb = row_colors[ri] 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): + """Single-plane easel + short stand — no dual-surface z-fight.""" me = bpy.data.meshes.new(name) bm = bmesh.new() try: @@ -610,19 +621,46 @@

Source

tex = nodes.new("ShaderNodeTexImage") tex.image = image tex.interpolation = "Closest" + links.new(tex.outputs["Color"], bsdf.inputs["Base Color"]) 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 + # Faint emission so clamps read under a dim stage without washing the wall. + bsdf.inputs["Emission Strength"].default_value = 0.22 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 + root = bpy.data.objects.new(name, None) + root.location = loc + root.rotation_euler = (math.radians(62), 0.0, math.radians(rot_z_deg)) + root.scale = (0.88, 0.88, 0.88) + bpy.context.collection.objects.link(root) + + face = bpy.data.objects.new(name + "Face", me) + bpy.context.collection.objects.link(face) + face.parent = root + + stand_me = bpy.data.meshes.new(name + "Stand") + bm = bmesh.new() + try: + bmesh.ops.create_cube(bm, size=1.0) + bm.to_mesh(stand_me) + finally: + bm.free() + smat = bpy.data.materials.new(name + "StandMat") + smat.use_nodes = True + sb = smat.node_tree.nodes["Principled BSDF"] + sb.inputs["Base Color"].default_value = (0.045, 0.048, 0.055, 1.0) + sb.inputs["Roughness"].default_value = 0.45 + if "Metallic" in sb.inputs: + sb.inputs["Metallic"].default_value = 0.3 + stand_me.materials.append(smat) + stand = bpy.data.objects.new(name + "Stand", stand_me) + # Fully below the card face — never intersects the evidence plane. + stand.scale = (0.06, 0.06, 0.28) + stand.location = (0.0, 0.04, -0.88) + bpy.context.collection.objects.link(stand) + stand.parent = root + return root def render_still(path, engine): @@ -631,8 +669,27 @@

Source

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) + # Mounted easels on a shared dark table — continuous base, no V-tear shadow. + easel_plane("PngPanel", png_card, (-1.15, 0.1, 1.28), 5) + easel_plane("ExrPanel", exr_card, (1.15, 0.1, 1.28), -5) + + table_me = bpy.data.meshes.new("Table") + bm = bmesh.new() + try: + bmesh.ops.create_cube(bm, size=1.0) + bm.to_mesh(table_me) + finally: + bm.free() + tmat = bpy.data.materials.new("TableMat") + tmat.use_nodes = True + tb = tmat.node_tree.nodes["Principled BSDF"] + tb.inputs["Base Color"].default_value = (0.022, 0.024, 0.028, 1.0) + tb.inputs["Roughness"].default_value = 0.6 + table_me.materials.append(tmat) + table = bpy.data.objects.new("Table", table_me) + table.scale = (1.5, 0.55, 0.08) + table.location = (0.0, 0.25, 0.28) + scene.collection.objects.link(table) floor_me = bpy.data.meshes.new("Floor") bm = bmesh.new() @@ -644,20 +701,20 @@

Source

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 + fb.inputs["Base Color"].default_value = (0.02, 0.021, 0.025, 1.0) + fb.inputs["Roughness"].default_value = 0.8 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.location = (0.0, 8.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, + 0.012, 0.013, 0.015, 1.0, ) scene.world = world @@ -671,19 +728,19 @@

Source

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)) + light("Key", (-3.8, -4.8, 5.8), 220.0, 4.5, (1.0, 0.96, 0.9), (52, 0, -32)) + light("Fill", (4.8, -2.8, 1.6), 40.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52)) + light("Rim", (0.2, 4.8, 2.8), 120.0, 3.2, (0.6, 0.78, 1.0), (-62, 0, 178)) + light("Wedge", (0.5, 6.5, 2.5), 450.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) + aim.location = (0.0, 0.1, 1.15) scene.collection.objects.link(aim) cam_data = bpy.data.cameras.new("Cam") - cam_data.lens = 45.0 + cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (0.0, -5.4, 2.2) + cam.location = (0.0, -6.0, 2.15) con = cam.constraints.new("TRACK_TO") con.target = aim con.track_axis = "TRACK_NEGATIVE_Z" diff --git a/examples/png-exr-alpha/png_exr_alpha.py b/examples/png-exr-alpha/png_exr_alpha.py index 6aec540..7a6df8c 100644 --- a/examples/png-exr-alpha/png_exr_alpha.py +++ b/examples/png-exr-alpha/png_exr_alpha.py @@ -344,8 +344,12 @@ def make_gallery_card(name, mangled): 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. + + A dark bezel is painted into the texture so panel edges read as framed + objects in the studio still (render-only — checks use fill_pattern). """ gw, gh = 256, 192 + border = 10 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) @@ -359,25 +363,32 @@ def make_gallery_card(name, mangled): (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) + iw, ih = gw - 2 * border, gh - 2 * border + col_w = max(1, iw // n_cols) + row_h = max(1, ih // len(row_colors)) + bezel = (0.04, 0.042, 0.048) 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) + i = (y * gw + x) * 4 + if x < border or y < border or x >= gw - border or y >= gh - border: + px[i : i + 4] = [bezel[0], bezel[1], bezel[2], 1.0] + continue + xi, yi = x - border, y - border + ri = min(yi // row_h, len(row_colors) - 1) + ci = min(xi // col_w, n_cols - 1) + rgb = row_colors[ri] 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): + """Single-plane easel + short stand — no dual-surface z-fight.""" me = bpy.data.meshes.new(name) bm = bmesh.new() try: @@ -402,19 +413,46 @@ def easel_plane(name, image, loc, rot_z_deg): tex = nodes.new("ShaderNodeTexImage") tex.image = image tex.interpolation = "Closest" + links.new(tex.outputs["Color"], bsdf.inputs["Base Color"]) 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 + # Faint emission so clamps read under a dim stage without washing the wall. + bsdf.inputs["Emission Strength"].default_value = 0.22 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 + root = bpy.data.objects.new(name, None) + root.location = loc + root.rotation_euler = (math.radians(62), 0.0, math.radians(rot_z_deg)) + root.scale = (0.88, 0.88, 0.88) + bpy.context.collection.objects.link(root) + + face = bpy.data.objects.new(name + "Face", me) + bpy.context.collection.objects.link(face) + face.parent = root + + stand_me = bpy.data.meshes.new(name + "Stand") + bm = bmesh.new() + try: + bmesh.ops.create_cube(bm, size=1.0) + bm.to_mesh(stand_me) + finally: + bm.free() + smat = bpy.data.materials.new(name + "StandMat") + smat.use_nodes = True + sb = smat.node_tree.nodes["Principled BSDF"] + sb.inputs["Base Color"].default_value = (0.045, 0.048, 0.055, 1.0) + sb.inputs["Roughness"].default_value = 0.45 + if "Metallic" in sb.inputs: + sb.inputs["Metallic"].default_value = 0.3 + stand_me.materials.append(smat) + stand = bpy.data.objects.new(name + "Stand", stand_me) + # Fully below the card face — never intersects the evidence plane. + stand.scale = (0.06, 0.06, 0.28) + stand.location = (0.0, 0.04, -0.88) + bpy.context.collection.objects.link(stand) + stand.parent = root + return root def render_still(path, engine): @@ -423,8 +461,27 @@ def render_still(path, engine): 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) + # Mounted easels on a shared dark table — continuous base, no V-tear shadow. + easel_plane("PngPanel", png_card, (-1.15, 0.1, 1.28), 5) + easel_plane("ExrPanel", exr_card, (1.15, 0.1, 1.28), -5) + + table_me = bpy.data.meshes.new("Table") + bm = bmesh.new() + try: + bmesh.ops.create_cube(bm, size=1.0) + bm.to_mesh(table_me) + finally: + bm.free() + tmat = bpy.data.materials.new("TableMat") + tmat.use_nodes = True + tb = tmat.node_tree.nodes["Principled BSDF"] + tb.inputs["Base Color"].default_value = (0.022, 0.024, 0.028, 1.0) + tb.inputs["Roughness"].default_value = 0.6 + table_me.materials.append(tmat) + table = bpy.data.objects.new("Table", table_me) + table.scale = (1.5, 0.55, 0.08) + table.location = (0.0, 0.25, 0.28) + scene.collection.objects.link(table) floor_me = bpy.data.meshes.new("Floor") bm = bmesh.new() @@ -436,20 +493,20 @@ def render_still(path, engine): 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 + fb.inputs["Base Color"].default_value = (0.02, 0.021, 0.025, 1.0) + fb.inputs["Roughness"].default_value = 0.8 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.location = (0.0, 8.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, + 0.012, 0.013, 0.015, 1.0, ) scene.world = world @@ -463,19 +520,19 @@ def light(name, loc, energy, size, col, rot): 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)) + light("Key", (-3.8, -4.8, 5.8), 220.0, 4.5, (1.0, 0.96, 0.9), (52, 0, -32)) + light("Fill", (4.8, -2.8, 1.6), 40.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52)) + light("Rim", (0.2, 4.8, 2.8), 120.0, 3.2, (0.6, 0.78, 1.0), (-62, 0, 178)) + light("Wedge", (0.5, 6.5, 2.5), 450.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) + aim.location = (0.0, 0.1, 1.15) scene.collection.objects.link(aim) cam_data = bpy.data.cameras.new("Cam") - cam_data.lens = 45.0 + cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (0.0, -5.4, 2.2) + cam.location = (0.0, -6.0, 2.15) con = cam.constraints.new("TRACK_TO") con.target = aim con.track_axis = "TRACK_NEGATIVE_Z" diff --git a/examples/png-exr-alpha/preview.webp b/examples/png-exr-alpha/preview.webp index 7e448c9..b419ced 100644 Binary files a/examples/png-exr-alpha/preview.webp and b/examples/png-exr-alpha/preview.webp differ