# Stage 29: level the repainted patches' tone using ON-BODY sampling (the only kind that works). # # blender --background --python 29_tone3d.py -- [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")