# Stage 21 (read-only): is the patchwork in the LAYOUT or in the COLOUR? # # blender --background --python 21_seam_probe.py -- # # 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")