# ============================================================================================ # 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 -- [--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")