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>
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
# 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")
|
||||
Reference in New Issue
Block a user