354 lines
14 KiB
Python
354 lines
14 KiB
Python
|
|
# 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")
|