Files

164 lines
6.8 KiB
Python
Raw Permalink Normal View History

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