Files
animation/characters/female/lena_nude/hires_claude/29_tone3d.py
T
jeremy 3ba86b2ea8 feat: clothing lane, character sources, and DCC bridges
Bulk import of the working lanes that were living untracked on the PC.

Content:
- characters/  Lena/male body lanes, bakes, texture work, run logs
- clothing/    garment pipeline, configs, gates, contract docs
- garments/    MD-authored garment sources (.zprj/.zpac)
- UAL-Lib/     Universal Animation Library 2 source (.blend/.fbx/.glb)
- tools/       blender_bridge, iclone_bridge, md_bridge, tailor, glm_agent
- docs/, plans/, dev/, .agents/plans/

Repo hygiene:
- .gitattributes: LFS now covers .blend, .zprj, .zpac, .obj, .npy and the
  Reallusion .iAvatar/.ccAvatar/.ccRestore containers. Without this the
  ~3.8 GB in this commit would land as raw blobs. .png/.jpg are left out
  on purpose — ~250 are already tracked raw and converting them would
  rewrite every one without shrinking history.
- .gitignore: exclude /accurig/ (~1 GB AccuRig program files, redistributable
  from Reallusion, nothing authored here) and /dev/null/ (git-lfs hook copies
  dropped by a `>/dev/null` redirect on Windows).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:55:43 -07:00

257 lines
9.6 KiB
Python

# Stage 29: level the repainted patches' tone using ON-BODY sampling (the only kind that works).
#
# blender --background --python 29_tone3d.py -- <in.blend> <out.blend> [orig.blend] [strength]
#
# WHY STAGE 21 FAILED AND WAS REJECTED (REJECTED_28_atlas_tone.blend)
# Stage 21 solved a Poisson correction whose boundary condition was the mismatch against texels
# ADJACENT IN THE ATLAS. That is the one mistake 05_texture.py's header explicitly warns about:
# "fill tone comes from skin NEAREST ON THE BODY (KD over skin verts in 3D), never from atlas
# neighbourhoods — atlas-local fills gave wrong tones and island seams". UV adjacency is not body
# adjacency: a patch border texel's atlas neighbours are frequently a different body part or empty
# gutter, so the boundary mismatch was garbage and the harmonic solve spread it over the whole
# patch. Result: the bra and briefs read as pale grey panels, far worse than the faint tone step we
# started from. The roughness pass compounded it (corrections up to +0.19 turned her plasticky).
#
# THIS STAGE DOES IT ON THE MESH.
# * a vertex is "garment" if its texel was repainted (mask = current vs pristine basecolor);
# * for each garment vertex, the target tone is an inverse-distance average of the 8 nearest
# SKIN vertices in 3D — real neighbours on the body, across UV seams, never a gutter;
# * correction = target - current, per vertex, then SMOOTHED over the mesh graph so only the
# low-frequency level is carried and the transplanted grain survives untouched;
# * the smoothed correction is rasterised over garment faces and added.
# Because the correction is smooth and vanishes where current tone already equals nearby skin, a
# well-matched region is left alone and only a genuine offset is removed.
#
# STRENGTH is deliberately conservative (default 0.75): the baseline is already close, and the
# failure mode of this whole family of fixes is overshoot.
import bpy, sys, os, time
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
ORIG_BLEND = argv[2] if len(argv) > 2 else "00_welded.blend"
STRENGTH = float(argv[3]) if len(argv) > 3 else 0.75
SMOOTH_ARG = int(argv[4]) if len(argv) > 4 else None
t0 = time.time()
DIFF_T = 0.02
SMOOTH_CORR = 120 # graph-smoothing passes on the correction field (low-frequency only)
KNN = 8
Z_LO, Z_HI = 0.25, 0.895 # sample skin on the body, never the face/lips/eyes
FEATHER = 3
def log(m):
print(f"[t3d {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)
# ---- pristine originals ----
bpy.ops.wm.open_mainfile(filepath=ORIG_BLEND)
CACHE = {}
for i in bpy.data.images:
nm = i.name.lower()
k = "base" if "basecolor" in nm else ("rm" if "_rm" in nm else None)
if k and k not in CACHE:
CACHE[k] = (getpx(i)[:, :, :3].astype(np.float32), tuple(i.size))
log(f"cached pristine: { {k: v[1] for k, v in 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))
me = ob.data
n_v = len(me.vertices)
wired = {}
for ms in ob.material_slots:
if not ms.material or not ms.material.node_tree:
continue
for n in ms.material.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:
wired["base"] = n.image
elif "separate" in tn:
wired["rm"] = n.image
log(f"body {n_v}v, wired { {k: v.name for k, v in wired.items()} }")
if "base" not in wired:
raise SystemExit("[t3d] FATAL: no wired basecolor")
co = np.empty(n_v * 3)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
base = wired["base"]
B4 = getpx(base)
B = B4[:, :, :3].astype(np.float64)
h, w = B.shape[:2]
Orig = CACHE["base"][0]
mask = np.abs(B - Orig).max(axis=2) > DIFF_T
log(f"repainted texels: {int(mask.sum())} ({100.0*mask.sum()/(w*h):.2f}%)")
# ---- per-vertex UV -> texel, colour, and garment flag ----
lv = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("vertex_index", lv)
uv = np.empty(len(me.loops) * 2)
me.uv_layers.active.data.foreach_get("uv", uv)
uv = uv.reshape(-1, 2)
px = np.clip(uv[:, 0], 0, 1) * (w - 1)
py = np.clip(uv[:, 1], 0, 1) * (h - 1)
pxi = px.astype(np.int32)
pyi = py.astype(np.int32)
first = np.full(n_v, -1, dtype=np.int64)
np.maximum.at(first, lv, np.arange(len(lv), dtype=np.int64))
has = first >= 0
vcol = np.zeros((n_v, 3))
vcol[has] = B[pyi[first[has]], pxi[first[has]]]
# a vertex is garment if ANY of its loops lands on a repainted texel
gv = np.zeros(n_v, dtype=bool)
np.logical_or.at(gv, lv, mask[pyi, pxi])
log(f"garment verts: {int(gv.sum())} of {n_v}")
zone = (co[:, 2] > Z_LO) & (co[:, 2] < Z_HI)
skin = (~gv) & zone & has
skin_idx = np.nonzero(skin)[0][::4]
log(f"skin sample for KD: {len(skin_idx)}")
kd = KDTree(len(skin_idx))
for j, i in enumerate(skin_idx):
kd.insert(Vector(co[i]), j)
kd.balance()
gidx = np.nonzero(gv & zone & has)[0]
target = np.zeros((n_v, 3))
for i in gidx:
hits = kd.find_n(Vector(co[i]), KNN)
wsum = 0.0
acc = np.zeros(3)
for (_, j, d) in hits:
wt = 1.0 / max(d * d, 1e-9)
acc += wt * vcol[skin_idx[j]]
wsum += wt
target[i] = acc / wsum
log("on-body target tones computed")
corr = np.zeros((n_v, 3))
corr[gidx] = target[gidx] - vcol[gidx]
pre = np.abs(corr[gidx]).mean(axis=0)
log(f"raw correction magnitude per channel: {pre.round(4)}")
# ---- smooth the correction over the mesh graph: keep only the level, not the detail ----
order = np.concatenate([ev[:, 0], ev[:, 1]])
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
srt = np.argsort(order, kind="stable")
o_s, n_s = order[srt], nbr[srt]
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
cnt = np.maximum(np.diff(ptr), 1)
empty = np.diff(ptr) == 0
if SMOOTH_ARG is not None:
SMOOTH_CORR = SMOOTH_ARG
for _ in range(SMOOTH_CORR):
a = np.add.reduceat(corr[n_s], ptr[:-1], axis=0)
a[empty] = corr[empty]
corr = a / cnt[:, None]
corr *= STRENGTH
log(f"smoothed x{SMOOTH_CORR}, strength {STRENGTH}: "
f"mean |corr| on garment {np.abs(corr[gidx]).mean(axis=0).round(4)}, "
f"max {np.abs(corr[gidx]).max():.4f}")
# ---- on-body verification metric: garment interior tone vs the skin ring around it, in 3D ----
def grow(m, k):
x = m.copy()
for _ in range(k):
hit = x[ev[:, 0]] | x[ev[:, 1]]
y = x.copy()
y[ev[:, 0]] |= hit
y[ev[:, 1]] |= hit
x = y
return x
inner_v = gv & ~grow(~gv, 6)
ring_v = grow(gv, 8) & ~gv & zone
if inner_v.sum() and ring_v.sum():
a0 = vcol[inner_v].mean(axis=0)
r0 = vcol[ring_v].mean(axis=0)
a1 = (vcol[inner_v] + corr[inner_v]).mean(axis=0)
log(f"ON-BODY tone gap (garment interior - surrounding skin):")
log(f" before {(a0-r0).round(4)} |gap| luma {abs(a0.mean()-r0.mean()):.4f}")
log(f" after {(a1-r0).round(4)} |gap| luma {abs(a1.mean()-r0.mean()):.4f}")
# ---- rasterise the correction over garment faces ----
n_f = len(me.polygons)
ls = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_start", ls)
lt = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_total", lt)
face_g = np.add.reduceat(gv[lv].astype(np.int32), ls) > 0
log(f"garment faces: {int(face_g.sum())}")
out = B.copy()
painted = np.zeros((h, w), dtype=bool)
for fi in np.nonzero(face_g)[0]:
s, t = int(ls[fi]), int(lt[fi])
li = np.arange(s, min(s + t, s + 3))
P = np.stack([px[li], py[li]], axis=1)
V = lv[li]
if len(P) < 3:
continue
x0, x1 = int(P[:, 0].min()), int(np.ceil(P[:, 0].max()))
y0, y1 = int(P[:, 1].min()), int(np.ceil(P[:, 1].max()))
if x1 - x0 > 128 or y1 - y0 > 128 or x1 < x0 or y1 < y0:
continue
d = ((P[1, 1] - P[2, 1]) * (P[0, 0] - P[2, 0]) +
(P[2, 0] - P[1, 0]) * (P[0, 1] - P[2, 1]))
if abs(d) < 1e-12:
continue
gx, gy = np.meshgrid(np.arange(x0, min(x1, w - 1) + 1),
np.arange(y0, min(y1, h - 1) + 1))
a = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / d
b = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / d
c = 1.0 - a - b
ins = (a >= -0.02) & (b >= -0.02) & (c >= -0.02)
if not ins.any():
continue
e = (a[ins, None] * corr[V[0]] + b[ins, None] * corr[V[1]] + c[ins, None] * corr[V[2]])
out[gy[ins], gx[ins]] += e
painted[gy[ins], gx[ins]] = True
log(f"rasterised correction over {int(painted.sum())} texels")
# only correct where the atlas was actually repainted, feathered at the rim
apply_m = painted & mask
alpha = apply_m.astype(np.float64)
edge = apply_m.copy()
for k in range(FEATHER):
g = edge.copy()
g[1:, :] |= edge[:-1, :]
g[:-1, :] |= edge[1:, :]
g[:, 1:] |= edge[:, :-1]
g[:, :-1] |= edge[:, 1:]
ring = g & ~edge
alpha[ring] = 1.0 - (k + 1) / (FEATHER + 1.0)
edge = g
final = np.clip(B * (1 - alpha[:, :, None]) + out * alpha[:, :, None], 0, 1)
log(f"applied to {int(apply_m.sum())} texels; "
f"mean shift {np.abs(final-B)[apply_m].mean():.5f}, max {np.abs(final-B).max():.4f}")
B4[:, :, :3] = final.astype(np.float32)
base.pixels.foreach_set(B4.reshape(-1))
base.pack()
log("basecolor written + packed")
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("T3D_DONE")