Files
animation/characters/work/lena/34_target_tone.py
T
jeremy 3d8825f5a9 reorg(characters): ship-time folders — lena_base_v01 ships, lane moves to work/lena
REGISTRY rewritten around the central rule: a character folder is born only
when a body ships to ariki-game (<character>_base_v<NN> = ship ordinal).
lena_nude dissolves accordingly:
- characters/female/lena_base_v01/ — SHIPPED 2026-08-10: AccuRig GLB carrier,
  T-pose/rig FBX + JSON, previews, frozen README
- characters/work/lena/ — the live lane: recipes 01-47 (incl. new 36-47:
  refill/sheets/clay/despeckle/musculature/spin/AccuRig export/graft/pose QC),
  masters (athletic_v04 blend + textures, accurig blend), lane-history README
- hires_claude/hires_work intermediates (blends, logs, probes) pruned

Supporting docs: AGENTS.md, working-files rule, rig-graft plan addendum,
originals README, prune_lane.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 07:17:15 -07:00

81 lines
3.0 KiB
Python

# Stage 34 (read-only): measure the APPROVED concept turnaround against our body, in the same
# units, so "uniform skin" stops being an adjective.
#
# blender --background --python 34_target_tone.py -- <concept_dir> <our_render.png>
#
# Both inputs are renders of a body on a flat grey background, so the comparison is like for like.
# Redness (r-g) is the useful statistic: it barely moves with lighting, so a body lit by one light
# should hold it nearly constant. Lena's Tripo texture does not — her feet measure 50% redder than
# her belly — and the question is what the approved reference does.
import bpy, sys, os
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
CONCEPT_DIR, OURS = argv[0], argv[1]
def load(p):
im = bpy.data.images.load(p)
w, h = im.size
b = np.empty(w * h * 4, dtype=np.float32)
im.pixels.foreach_get(b)
a = b.reshape(h, w, 4)[:, :, :3].copy()
bpy.data.images.remove(im)
return a[::-1] # Blender rows are bottom-up; flip to image order
def body_mask(A):
# background is flat and desaturated; skin is not
sat = A.max(axis=2) - A.min(axis=2)
return (sat > 0.06) & (A.max(axis=2) > 0.15)
def report(name, A):
m = body_mask(A)
if m.sum() < 500:
print(f"{name}: no body found")
return
ys, xs = np.nonzero(m)
y0, y1 = ys.min(), ys.max()
rg = (A[:, :, 0] - A[:, :, 1])
luma = A.mean(axis=2)
print(f"\n=== {name} === {m.sum()} body pixels, rows {y0}..{y1}")
print(f" WHOLE BODY r-g mean {rg[m].mean():.3f} std {rg[m].std():.3f} "
f"luma mean {luma[m].mean():.3f}")
# slice by height: 0 = top of the figure (head), 1 = bottom (feet)
print(" by height band (0=head .. 1=feet):")
rows = []
for i in range(10):
a = y0 + (y1 - y0) * i / 10.0
b = y0 + (y1 - y0) * (i + 1) / 10.0
band = np.zeros_like(m)
band[int(a):int(b) + 1, :] = True
mm = m & band
if mm.sum() < 200:
continue
rows.append((i / 10.0, rg[mm].mean(), luma[mm].mean(), int(mm.sum())))
print(f" {i/10.0:.1f}-{(i+1)/10.0:.1f} r-g {rg[mm].mean():.3f} "
f"luma {luma[mm].mean():.3f} n={int(mm.sum())}")
if rows:
arr = np.array([r[1] for r in rows])
print(f" SPREAD of r-g across height bands: {arr.max()-arr.min():.3f} "
f"(min {arr.min():.3f} at {rows[int(np.argmin(arr))][0]:.1f}, "
f"max {arr.max():.3f} at {rows[int(np.argmax(arr))][0]:.1f})")
return rg[m], luma[m]
res = {}
for f in sorted(os.listdir(CONCEPT_DIR)):
if f.lower().endswith(".png"):
res[f] = report("CONCEPT " + f, load(os.path.join(CONCEPT_DIR, f)))
ours = report("OURS " + os.path.basename(OURS), load(OURS))
print("\n" + "=" * 72)
print("VERDICT — how much does redness vary over the body?")
for k, v in res.items():
if v:
print(f" concept {k:<32} r-g std {v[0].std():.4f}")
if ours:
print(f" ours {os.path.basename(OURS):<32} r-g std {ours[0].std():.4f}")
print("TARGET_TONE_DONE")