3ba86b2ea8
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>
164 lines
6.8 KiB
Python
164 lines
6.8 KiB
Python
# Stage 21 (read-only): is the patchwork in the LAYOUT or in the COLOUR?
|
|
#
|
|
# blender --background --python 21_seam_probe.py -- <in.blend> <probe_dir>
|
|
#
|
|
# A mesh vertex that sits on a UV seam has one copy in each atlas chart that meets there. Those
|
|
# copies are the SAME point on her body, so they must be the same colour. Any difference is a
|
|
# tone step the eye reads as a pasted edge — and re-packing the UVs would carry it along.
|
|
# Reports the distribution of that step, the worst offending chart pairs, and (for scale) the
|
|
# same statistic on non-seam vertices, which is pure sampling noise.
|
|
# Also reports mean tone per body region, to size the "red hands / rosy chest" complaint.
|
|
import bpy, sys, os, time
|
|
import numpy as np
|
|
|
|
argv = sys.argv[sys.argv.index("--") + 1:]
|
|
BLEND = argv[0]
|
|
PROBE = os.path.abspath(argv[1])
|
|
t0 = time.time()
|
|
|
|
|
|
def log(m):
|
|
print(f"[seam {time.time()-t0:6.1f}s] {m}", flush=True)
|
|
|
|
|
|
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, n_l, n_f = len(me.vertices), len(me.loops), len(me.polygons)
|
|
|
|
base = None
|
|
for slot in ob.material_slots:
|
|
mat = slot.material
|
|
for node in mat.node_tree.nodes:
|
|
if node.type == 'BSDF_PRINCIPLED' and node.inputs["Base Color"].links:
|
|
src = node.inputs["Base Color"].links[0].from_node
|
|
if src.type == 'TEX_IMAGE':
|
|
base = src.image
|
|
W, H = base.size
|
|
buf = np.empty(W * H * 4, dtype=np.float32)
|
|
base.pixels.foreach_get(buf)
|
|
tex = buf.reshape(H, W, 4)[:, :, :3].astype(np.float32)
|
|
log(f"basecolor '{base.name}' {W}x{H}")
|
|
|
|
loops_v = np.empty(n_l, dtype=np.int32); me.loops.foreach_get("vertex_index", loops_v)
|
|
uv = np.empty(n_l * 2); me.uv_layers.active.data.foreach_get("uv", uv); uv = uv.reshape(-1, 2)
|
|
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
|
|
|
|
C = np.load(os.path.join(PROBE, "uv_cache.npz"))
|
|
uvv, isl = C["uvv"], C["isl"]
|
|
l_isl = isl[uvv] # island id per loop
|
|
l_start = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_start", l_start)
|
|
|
|
# inset each loop's UV 25% toward its face centroid so we read chart interior, not padding
|
|
face_of_loop = np.repeat(np.arange(n_f), 3) # all-tri mesh
|
|
cen = (uv[l_start[face_of_loop]] + uv[l_start[face_of_loop] + 1]
|
|
+ uv[l_start[face_of_loop] + 2]) / 3.0
|
|
uvi = uv + 0.25 * (cen - uv)
|
|
px = np.clip(np.round(uvi[:, 0] * (W - 1)).astype(np.int32), 0, W - 1)
|
|
py = np.clip(np.round(uvi[:, 1] * (H - 1)).astype(np.int32), 0, H - 1)
|
|
lc = tex[py, px] # colour per loop
|
|
log("sampled per-loop colour")
|
|
|
|
# ---- per (vertex, island) mean colour ----
|
|
key = loops_v.astype(np.int64) * (isl.max() + 1) + l_isl
|
|
uk, inv = np.unique(key, return_inverse=True)
|
|
n_k = len(uk)
|
|
cnt = np.bincount(inv, minlength=n_k).astype(np.float64)
|
|
acc = np.zeros((n_k, 3))
|
|
for c in range(3):
|
|
acc[:, c] = np.bincount(inv, weights=lc[:, c], minlength=n_k)
|
|
kc = acc / cnt[:, None]
|
|
kv = (uk // (isl.max() + 1)).astype(np.int64) # vertex of each (v,island) group
|
|
ki = (uk % (isl.max() + 1)).astype(np.int64) # island of each group
|
|
|
|
order = np.argsort(kv, kind="stable")
|
|
kv_s, kc_s, ki_s = kv[order], kc[order], ki[order]
|
|
ptr = np.searchsorted(kv_s, np.arange(n_v + 1))
|
|
ncopy = np.diff(ptr)
|
|
|
|
seam_v = np.nonzero(ncopy > 1)[0]
|
|
log(f"seam vertices: {len(seam_v)} (max copies {ncopy.max()})")
|
|
|
|
steps = []
|
|
pairstep = {}
|
|
for v in seam_v:
|
|
s, e = ptr[v], ptr[v + 1]
|
|
cc = kc_s[s:e]
|
|
ii = ki_s[s:e]
|
|
d = np.abs(cc[:, None, :] - cc[None, :, :]).max(axis=2)
|
|
a, b = np.unravel_index(np.argmax(d), d.shape)
|
|
steps.append(d[a, b])
|
|
if d[a, b] > 0.02:
|
|
kpair = (int(min(ii[a], ii[b])), int(max(ii[a], ii[b])))
|
|
r = pairstep.setdefault(kpair, [0, 0.0])
|
|
r[0] += 1
|
|
r[1] += float(d[a, b])
|
|
steps = np.array(steps)
|
|
|
|
# baseline: colour spread among the loops of a NON-seam vertex (sampling noise only)
|
|
solo = np.nonzero(ncopy == 1)[0]
|
|
sample = solo[::max(1, len(solo) // 40000)]
|
|
noise = []
|
|
lorder = np.argsort(loops_v, kind="stable")
|
|
lv_s = loops_v[lorder]
|
|
lptr = np.searchsorted(lv_s, np.arange(n_v + 1))
|
|
for v in sample:
|
|
li = lorder[lptr[v]:lptr[v + 1]]
|
|
if len(li) < 2:
|
|
continue
|
|
noise.append(np.abs(lc[li].max(axis=0) - lc[li].min(axis=0)).max())
|
|
noise = np.array(noise)
|
|
|
|
print("\n=== COLOUR STEP ACROSS CHART BORDERS ===")
|
|
print("(max channel difference between copies of the SAME body point in different charts)")
|
|
for p in (50, 75, 90, 95, 99):
|
|
print(f" seam p{p:<2d} {np.percentile(steps, p):.4f}")
|
|
print(f" seam mean {steps.mean():.4f} >0.02: {100.0*(steps>0.02).mean():.1f}% "
|
|
f">0.05: {100.0*(steps>0.05).mean():.1f}% >0.10: {100.0*(steps>0.10).mean():.1f}%")
|
|
print(f" NOISE floor (non-seam vertices) p50 {np.percentile(noise,50):.4f} "
|
|
f"p95 {np.percentile(noise,95):.4f} mean {noise.mean():.4f}")
|
|
print(f" -> seam step is {steps.mean()/max(noise.mean(),1e-9):.1f}x the noise floor")
|
|
|
|
print("\nworst chart pairs (count of stepped verts, mean step):")
|
|
tops = sorted(pairstep.items(), key=lambda kv_: -kv_[1][1])[:12]
|
|
for (a, b), (n_, s_) in tops:
|
|
print(f" chart {a:5d} <-> {b:5d}: {n_:5d} verts, mean step {s_/n_:.4f}")
|
|
|
|
# ---- per-region tone (the red hands / rosy chest complaint, as numbers) ----
|
|
vc = np.zeros((n_v, 3))
|
|
vn = np.zeros(n_v)
|
|
for c in range(3):
|
|
vc[:, c] = np.bincount(loops_v, weights=lc[:, c], minlength=n_v)
|
|
vn = np.bincount(loops_v, minlength=n_v).astype(np.float64)
|
|
vc /= np.maximum(vn, 1)[:, None]
|
|
z, x, y = co[:, 2], co[:, 0], co[:, 1]
|
|
regions = {
|
|
"head ": z > 0.905,
|
|
"neck/upper chest": (z > 0.82) & (z <= 0.905) & (np.abs(x) < 0.09),
|
|
"breast band ": (z > 0.60) & (z <= 0.78) & (np.abs(x) < 0.11) & (y < 0),
|
|
"belly ": (z > 0.45) & (z <= 0.60) & (np.abs(x) < 0.09) & (y < 0),
|
|
"hip/crotch ": (z > 0.33) & (z <= 0.45) & (np.abs(x) < 0.09),
|
|
"upper arm ": (z > 0.70) & (np.abs(x) > 0.16) & (np.abs(x) < 0.30),
|
|
"forearm ": (np.abs(x) > 0.30) & (np.abs(x) < 0.40),
|
|
"hand ": np.abs(x) > 0.40,
|
|
"thigh ": (z > 0.20) & (z <= 0.33),
|
|
"shin ": (z > 0.06) & (z <= 0.18),
|
|
"foot ": z <= 0.05,
|
|
}
|
|
print("\n=== TONE BY REGION (mean RGB, and r-g redness) ===")
|
|
belly_rg = None
|
|
for nm, m in regions.items():
|
|
if m.sum() < 50:
|
|
print(f" {nm} (empty)")
|
|
continue
|
|
c_ = vc[m].mean(axis=0)
|
|
rg = c_[0] - c_[1]
|
|
if nm.startswith("belly"):
|
|
belly_rg = rg
|
|
print(f" {nm} n={int(m.sum()):7d} RGB {c_[0]:.3f} {c_[1]:.3f} {c_[2]:.3f} "
|
|
f"r-g {rg:.3f} luma {c_.mean():.3f}")
|
|
if belly_rg is not None:
|
|
print(f" (belly r-g = {belly_rg:.3f} is the reference 'plain skin' redness)")
|
|
np.save(os.path.join(PROBE, "vert_colour.npy"), vc.astype(np.float32))
|
|
print("SEAM_PROBE_DONE")
|