Files
animation/characters/work/lena/21_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

371 lines
16 KiB
Python

# ============================================================================================
# REJECTED 2026-08-06 — DO NOT RUN. Superseded by 29_tone3d.py.
#
# This levels tone against texels that are ADJACENT IN THE ATLAS. UV adjacency is not body
# adjacency: a patch border texel's neighbours are frequently a different body part or empty
# gutter, so the boundary mismatch is garbage and the harmonic solve then spreads that garbage
# across the whole patch. Run on v03 it turned the bra and briefs into pale grey panels —
# visibly WORSE than the faint tone step it was meant to remove. The evidence is kept in
# REJECTED_beauty_v03/ (compare against beauty_v03b/).
#
# 05_texture.py's own header warned about exactly this: tone must come from skin NEAREST ON THE
# BODY, never from atlas neighbourhoods. Use 29_tone3d.py, which samples the 8 nearest skin verts
# in 3D and smooths the correction over the mesh graph.
#
# Kept only as the record of the dead end. The everything-below still describes the approach as
# if it were sound; it is not.
# ============================================================================================
# Stage 21: kill the discolouration in the repainted bra/crotch patches — gradient-domain
# levelling of basecolor + roughness, and grain in place of the flat normal.
#
# blender --background --python 21_tone.py -- <in.blend> <out.blend> [--no-normal-grain]
#
# WHY THE PATCHES READ AS DISCOLOURED
# 05_texture.py fills each garment texel with an inverse-distance colour taken from the nearest
# SKIN VERTS IN 3D, mirror-averaged left/right, then feathers the rim 4 px. Every one of those
# choices is right for avoiding a wrong-body-part tone, and none of them controls the patch's
# ABSOLUTE level: the fill is an average of skin a few centimetres away, so wherever her skin has
# a gradient (and her chest does — there is a rosy blush the uniform-skin decision repaints), the
# patch lands at a different tone than the skin it abuts. A feather blurs that step over 4 px; it
# cannot remove it. The blend also flattens the normal map to (128,128,255) and sets roughness to
# the atlas median over the same texels, so the patch is smoother AND differently-glossy than the
# skin around it — under a key light that reads as discolouration even where the albedo matches.
#
# THE FIX — solve for the level instead of averaging toward it.
# Classic gradient-domain (Poisson) levelling: keep the fill's detail, replace its level. Find a
# correction field E over the patch that is harmonic inside and, ON THE PATCH BORDER, equals the
# mismatch against the untouched skin next to it:
# D(b) = mean(orig[n] : n neighbour of b, n outside the patch) - current[b]
# laplace(E) = 0 inside, E = D on the border, new = current + E
# At the border the corrected value becomes exactly its neighbours' value, so the seam cannot be
# seen; inward, E decays smoothly, so a uniform offset over the whole patch is removed too. Detail
# is untouched because E is smooth by construction — this levels the patch without blurring it.
#
# Solved per blob with a cascadic multigrid (coarse solve -> upsample -> refine). A flat Jacobi
# sweep would need ~width^2 iterations to converge; that mistake is already recorded in this
# project's history as the "pale panty ghost" (400 passes on a 600 px hole left the interior at
# its seed tone), so it is not repeated.
#
# The patch mask is not guessed: it is where the wired basecolor differs from the untouched
# original, which still sits in the file as an orphan datablock copy left by the raw-glb imports.
import bpy, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
# The untouched pre-repaint textures must come from a SEPARATE blend. They used to survive inside
# the working file as orphan ".002/.003" copies left by the raw-glb imports, but Blender purges
# zero-user datablocks on save, so they died the moment 22_lines.blend was written. 00_welded.blend
# is the pristine import and is the right source.
ORIG_BLEND = argv[2] if len(argv) > 2 and not argv[2].startswith("--") else "00_welded.blend"
DO_NORMAL_GRAIN = "--no-normal-grain" not in argv
t0 = time.time()
DIFF_T = 0.02 # a texel counts as repainted if any channel moved this much
RING = 10 # how far out to look for untouched skin
GRAIN_T = 16
def log(m):
print(f"[tone {time.time()-t0:6.1f}s] {m}", flush=True)
def getpx(img):
w, h = img.size
b = np.empty(w * h * 4, dtype=np.float32)
img.pixels.foreach_get(b)
return b.reshape(h, w, 4)
def dil(m, k=1):
g = m.copy()
for _ in range(k):
n = g.copy()
n[1:, :] |= g[:-1, :]
n[:-1, :] |= g[1:, :]
n[:, 1:] |= g[:, :-1]
n[:, :-1] |= g[:, 1:]
g = n
return g
# ---- cache the untouched originals from the pristine blend, before opening the working file ----
if not os.path.exists(ORIG_BLEND):
raise SystemExit(f"[tone] FATAL: original blend not found: {ORIG_BLEND}")
bpy.ops.wm.open_mainfile(filepath=ORIG_BLEND)
ORIG_CACHE = {}
for i in bpy.data.images:
nm = i.name.lower()
kind = ("base" if "basecolor" in nm else
"rm" if "_rm" in nm else
"normal" if "normal" in nm else None)
if kind and kind not in ORIG_CACHE:
ORIG_CACHE[kind] = (getpx(i)[:, :, :3].astype(np.float64), tuple(i.size), i.name)
log(f"cached originals from {ORIG_BLEND}: "
f"{ {k: (v[2], v[1]) for k, v in ORIG_CACHE.items()} }")
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
log(f"body {ob.name} {len(ob.data.vertices)}v")
# ---- which image is WIRED, and which orphan copy is the untouched original ----
wired = {}
for ms in ob.material_slots:
mat = ms.material
if not mat or not mat.node_tree:
continue
for n in mat.node_tree.nodes:
if n.type != 'TEX_IMAGE' or not n.image:
continue
for o in n.outputs:
for lk in o.links:
tn = lk.to_node.name.lower()
if "principled" in tn or lk.to_socket.name == "Base Color":
wired["base"] = n.image
elif "normal map" in tn:
wired["normal"] = n.image
elif "separate" in tn:
wired["rm"] = n.image
log(f"wired: { {k: v.name for k, v in wired.items()} }")
if "base" not in wired:
raise SystemExit("[tone] FATAL: could not find the wired basecolor")
def find_original(kind, target):
"""The cached pristine version of this map, checked for size and for actually differing."""
if kind not in ORIG_CACHE:
return None
arr, size, nm = ORIG_CACHE[kind]
if size != tuple(target.size):
log(f" {kind}: size {size} != wired {tuple(target.size)} — unusable")
return None
changed = int((np.abs(getpx(target)[:, :, :3] - arr).max(axis=2) > DIFF_T).sum())
log(f" {kind}: original '{nm}', {changed} texels differ from wired")
if changed < 1000:
return None
return (arr, changed, nm)
orig = find_original("base", wired["base"])
if orig is None:
raise SystemExit("[tone] FATAL: no usable untouched original basecolor")
O, n_changed, oname = orig
log(f"original = '{oname}' ({n_changed} texels differ)")
A4 = getpx(wired["base"])
A = A4[:, :, :3].astype(np.float64)
h, w = A.shape[:2]
mask = np.abs(A - O).max(axis=2) > DIFF_T
log(f"patch mask: {int(mask.sum())} texels ({100.0*mask.sum()/(w*h):.2f}% of atlas)")
# ---- split into blobs so each is levelled against ITS OWN surroundings ----
def blobs_of(m, min_px=1500):
lab = np.zeros(m.shape, dtype=np.int32)
cur = 0
out = []
ys, xs = np.nonzero(m)
seen = np.zeros(m.shape, dtype=bool)
from collections import deque
for y0, x0 in zip(ys, xs):
if seen[y0, x0]:
continue
cur += 1
q = deque([(y0, x0)])
seen[y0, x0] = True
cells = []
while q:
y, x = q.popleft()
cells.append((y, x))
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
yy, xx = y + dy, x + dx
if 0 <= yy < m.shape[0] and 0 <= xx < m.shape[1] \
and m[yy, xx] and not seen[yy, xx]:
seen[yy, xx] = True
q.append((yy, xx))
if len(cells) >= min_px:
lab[tuple(np.array(cells).T)] = cur
out.append((cur, len(cells)))
return lab, out
lab, blist = blobs_of(mask)
log(f"blobs >=1500 px: {len(blist)} (covering {sum(b[1] for b in blist)} texels)")
def solve_level(cur_img, orig_img, m_blob, tag):
"""Harmonic correction field E over m_blob with border BC = local mismatch vs untouched skin.
Returns E (same shape as the crop) and diagnostics."""
ys, xs = np.nonzero(m_blob)
y0, y1 = max(0, ys.min() - RING - 2), min(h, ys.max() + RING + 3)
x0, x1 = max(0, xs.min() - RING - 2), min(w, xs.max() + RING + 3)
M = m_blob[y0:y1, x0:x1]
C = cur_img[y0:y1, x0:x1]
Og = orig_img[y0:y1, x0:x1]
allm = mask[y0:y1, x0:x1]
# untouched skin usable as a reference: outside EVERY patch, and plausibly skin
lum = Og.mean(axis=2)
usable = (~allm) & (lum > 0.12)
# robust reject: compare to the median of the ring around this blob
ring = dil(M, RING) & usable
if ring.sum() < 50:
return None, None
med = np.median(Og[ring], axis=0)
mad = np.median(np.abs(Og[ring] - med), axis=0) + 1e-4
ok = (np.abs(Og - med) < (6.0 * mad)).all(axis=2) & usable
# border texels of the blob, and their mismatch D
nb_sum = np.zeros_like(C)
nb_cnt = np.zeros(M.shape)
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
Sh = np.roll(Og * ok[:, :, None], (dy, dx), axis=(0, 1))
Wh = np.roll(ok.astype(np.float64), (dy, dx), axis=(0, 1))
nb_sum += Sh
nb_cnt += Wh
border = M & (nb_cnt > 0)
if border.sum() < 20:
return None, None
D = np.zeros_like(C)
D[border] = nb_sum[border] / nb_cnt[border, None] - C[border]
# cascadic multigrid: solve coarse, upsample, refine
def restrict(x, msk):
Hh, Ww = x.shape[:2]
H2, W2 = (Hh + 1) // 2, (Ww + 1) // 2
acc = np.zeros((H2, W2, x.shape[2]))
cw = np.zeros((H2, W2))
for dy in (0, 1):
for dx in (0, 1):
sub = x[dy::2, dx::2]
sm = msk[dy::2, dx::2].astype(np.float64)
acc[:sub.shape[0], :sub.shape[1]] += sub * sm[:, :, None]
cw[:sub.shape[0], :sub.shape[1]] += sm
out = np.zeros_like(acc)
nz = cw > 0
out[nz] = acc[nz] / cw[nz, None]
return out, cw > 0
levels = []
Mi, Di, Bi = M, D, border
while min(Mi.shape[:2]) > 8 and len(levels) < 7:
levels.append((Mi, Di, Bi))
Dn, _ = restrict(Di, Bi)
Mn = restrict(Mi[:, :, None].astype(np.float64), Mi)[1]
Bn = restrict(Bi[:, :, None].astype(np.float64), Bi)[1]
Mi, Di, Bi = Mn, Dn, Bn
E = np.zeros(levels[-1][0].shape + (3,))
for li in range(len(levels) - 1, -1, -1):
Ml, Dl, Bl = levels[li]
if E.shape[:2] != Ml.shape[:2]:
Eu = np.repeat(np.repeat(E, 2, axis=0), 2, axis=1)
E = Eu[:Ml.shape[0], :Ml.shape[1]]
E[Bl] = Dl[Bl]
interior = Ml & ~Bl
sweeps = 400 if li >= len(levels) - 2 else 60
for _ in range(sweeps):
acc = np.zeros_like(E)
cw = np.zeros(E.shape[:2])
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
acc += np.roll(E * Ml[:, :, None], (dy, dx), axis=(0, 1))
cw += np.roll(Ml.astype(np.float64), (dy, dx), axis=(0, 1))
nz = interior & (cw > 0)
E[nz] = acc[nz] / cw[nz, None]
E[Bl] = Dl[Bl]
inner = M & ~dil(~M, 5)
diag = dict(
n=int(M.sum()),
border=int(border.sum()),
pre=(float(np.mean(C[inner].mean(axis=1) - med.mean())) if inner.sum() else float('nan')),
Emean=float(E[M].mean()),
Emax=float(np.abs(E[M]).max()),
)
return (slice(y0, y1), slice(x0, x1), M, E), diag
# =============================================================================
# apply to basecolor
# =============================================================================
def level_image(img, orig_np, label):
P4 = getpx(img)
P = P4[:, :, :3].astype(np.float64)
total = np.zeros_like(P)
touched = np.zeros(P.shape[:2], dtype=bool)
for bid, npx in sorted(blist, key=lambda t: -t[1]):
mb = lab == bid
res, diag = solve_level(P, orig_np, mb, f"{label}#{bid}")
if res is None:
log(f" {label} blob{bid}: skipped (no usable surrounding skin)")
continue
sy, sx, M, E = res
total[sy, sx][M] += E[M]
touched[sy, sx] |= M
log(f" {label} blob{bid}: {npx:7d} px border {diag['border']:6d} "
f"interior offset vs ring {diag['pre']:+.4f} -> correction mean "
f"{diag['Emean']:+.4f} (max |E| {diag['Emax']:.4f})")
out = np.clip(P + total, 0.0, 1.0)
# report the residual step across the patch border
b_in = touched & ~dil(~touched, 2)
b_out = dil(touched, 3) & ~touched
if b_in.any() and b_out.any():
log(f" {label}: border step before {abs(P[b_in].mean()-P[b_out].mean()):.4f} "
f"-> after {abs(out[b_in].mean()-out[b_out].mean()):.4f}")
P4[:, :, :3] = out.astype(np.float32)
img.pixels.foreach_set(P4.reshape(-1))
img.pack()
log(f" {label}: written + packed ({int(touched.sum())} texels corrected)")
return touched
tch = level_image(wired["base"], O, "basecolor")
# roughness/metallic: same levelling, so the patch stops reading as a different material
if "rm" in wired:
rm_orig = find_original("rm", wired["rm"])
if rm_orig is not None and tuple(wired["rm"].size) == (w, h):
level_image(wired["rm"], rm_orig[0], "rm")
else:
log("rm: no original copy or size mismatch — skipped")
# normal: the patch is perfectly flat; transplant skin grain so it stops reading as a decal
if DO_NORMAL_GRAIN and "normal" in wired and tuple(wired["normal"].size) == (w, h):
NM4 = getpx(wired["normal"])
NM = NM4[:, :, :3].astype(np.float64)
src_ok = ~dil(mask, 6)
def box1(a, r):
def b1(x, axis):
p = [(0, 0)] * x.ndim
p[axis] = (r, r)
cs = np.cumsum(np.pad(x, p, mode="edge"), axis=axis)
return (np.take(cs, np.arange(2 * r, cs.shape[axis]), axis=axis) -
np.take(cs, np.arange(0, cs.shape[axis] - 2 * r), axis=axis)) / (2 * r)
return b1(b1(a, 0), 1)
grain = np.stack([NM[:, :, c] - box1(NM[:, :, c], 5) for c in range(3)], axis=2)
cand = []
for ty in range(0, h - GRAIN_T, GRAIN_T):
for tx in range(0, w - GRAIN_T, GRAIN_T):
if src_ok[ty:ty + GRAIN_T, tx:tx + GRAIN_T].all():
cand.append((ty, tx))
rng = np.random.RandomState(1234)
cov = 0
for ty in range(0, h - GRAIN_T + 1, GRAIN_T):
for tx in range(0, w - GRAIN_T + 1, GRAIN_T):
tm = mask[ty:ty + GRAIN_T, tx:tx + GRAIN_T]
if not tm.any() or not cand:
continue
sy, sx = cand[rng.randint(len(cand))]
blk = NM[ty:ty + GRAIN_T, tx:tx + GRAIN_T]
blk[tm] += grain[sy:sy + GRAIN_T, sx:sx + GRAIN_T][tm]
cov += int(tm.sum())
NM4[:, :, :3] = np.clip(NM, 0, 1).astype(np.float32)
wired["normal"].pixels.foreach_set(NM4.reshape(-1))
wired["normal"].pack()
log(f"normal grain: {cov} texels from {len(cand)} clean tiles + packed")
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("TONE_DONE")