3d8825f5a9
REGISTRY rewritten around the central rule: a character folder is born only when a body ships to ariki-game (<character>_base_v<NN> = ship ordinal). lena_nude dissolves accordingly: - characters/female/lena_base_v01/ — SHIPPED 2026-08-10: AccuRig GLB carrier, T-pose/rig FBX + JSON, previews, frozen README - characters/work/lena/ — the live lane: recipes 01-47 (incl. new 36-47: refill/sheets/clay/despeckle/musculature/spin/AccuRig export/graft/pose QC), masters (athletic_v04 blend + textures, accurig blend), lane-history README - hires_claude/hires_work intermediates (blends, logs, probes) pruned Supporting docs: AGENTS.md, working-files rule, rig-graft plan addendum, originals README, prune_lane.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
294 lines
13 KiB
Python
294 lines
13 KiB
Python
# ============================================================================================
|
|
# CAUTION — ONE TEST IN HERE IS MISLEADING. Read this before trusting its output.
|
|
#
|
|
# The SPLIT-PANEL test reports what fraction of kink verts have a non-adjacent vertex within
|
|
# 0.8 mm. That threshold is BELOW the mesh's 1.85 mm mean edge length, so ordinary 2-ring
|
|
# neighbours are counted as "unmerged twins". It reported 58.7% and was read as proof that the
|
|
# scan seams are cracks — they are not, and acting on that reading tore the mesh (see
|
|
# 17_panel_weld.py). It also lacks a control: quiet skin scores similarly by the same test.
|
|
#
|
|
# 16c_topology.py does this correctly — it excludes the 3-ring neighbourhood, normalises by LOCAL
|
|
# edge length, and runs a control group. Use that one for any topology question.
|
|
#
|
|
# Every other measurement here (boundary/non-manifold counts, kink histograms, cleavage profile,
|
|
# texture patch offsets) is sound and was used to drive the v03 fixes.
|
|
# ============================================================================================
|
|
# Stage 16 (diagnosis only — writes nothing): measure the three defects Jeremy named, so the
|
|
# fixes target what is actually there instead of repeating stages 11-15.
|
|
#
|
|
# blender --background --python 16_diagnose.py -- <in.blend> <orig.blend> [scratch_dir]
|
|
#
|
|
# 1. CUT LINES. Are they still topology (disconnected panel runs / holes) after stage 15's weld,
|
|
# or are they now a purely geometric groove? Reports boundary edges, non-manifold edges,
|
|
# degenerate faces, and — for the strongest shading-kink clusters — the groove depth in mm
|
|
# measured perpendicular to the line. Depth tells us whether to weld harder or to fillet.
|
|
# 2. DISCOLOURATION. Finds the repainted texels by diffing this blend's packed basecolor against
|
|
# the ORIGINAL texture, then reports mean RGB inside the patch vs a ring of untouched skin
|
|
# just outside it. A tone STEP at the boundary is a Poisson problem; a uniform offset over the
|
|
# whole patch is a levelling problem. The numbers separate them.
|
|
# 3. CLEAVAGE. Samples the medial (sternum) corridor for concavity: minimum principal-curvature
|
|
# radius per height, so "sharp crease" vs "round fillet" is a number, not an opinion.
|
|
import bpy, bmesh, sys, os, time, math
|
|
import numpy as np
|
|
|
|
argv = sys.argv[sys.argv.index("--") + 1:]
|
|
BLEND = argv[0]
|
|
ORIG = argv[1] if len(argv) > 1 else ""
|
|
SCRATCH = argv[2] if len(argv) > 2 else "."
|
|
t0 = time.time()
|
|
|
|
|
|
def log(m):
|
|
print(f"[diag {time.time()-t0:6.1f}s] {m}", flush=True)
|
|
|
|
|
|
def body_of():
|
|
return max([o for o in bpy.data.objects if o.type == 'MESH'],
|
|
key=lambda o: len(o.data.vertices))
|
|
|
|
|
|
# =============================================================================
|
|
# 1. TOPOLOGY + CUT LINES
|
|
# =============================================================================
|
|
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
|
ob = body_of()
|
|
me = ob.data
|
|
n_v = len(me.vertices)
|
|
n_f = len(me.polygons)
|
|
log(f"mesh '{ob.name}': {n_v} verts, {n_f} faces, custom_normals={me.has_custom_normals}")
|
|
|
|
co = np.empty(n_v * 3)
|
|
me.vertices.foreach_get("co", co)
|
|
co = co.reshape(-1, 3)
|
|
print(f"BBOX z {co[:,2].min():.3f}..{co[:,2].max():.3f} "
|
|
f"x {co[:,0].min():.3f}..{co[:,0].max():.3f} y {co[:,1].min():.3f}..{co[:,1].max():.3f}")
|
|
|
|
bm = bmesh.new()
|
|
bm.from_mesh(me)
|
|
bnd = [e for e in bm.edges if len(e.link_faces) == 1]
|
|
nonman = [e for e in bm.edges if len(e.link_faces) > 2]
|
|
degen = [f for f in bm.faces if f.calc_area() < 1e-12]
|
|
loose = [v for v in bm.verts if not v.link_faces]
|
|
print(f"TOPO boundary_edges={len(bnd)} nonmanifold_edges={len(nonman)} "
|
|
f"degenerate_faces={len(degen)} loose_verts={len(loose)}")
|
|
# where are the holes? cluster boundary verts by height
|
|
if bnd:
|
|
bz = np.array([v.co.z for e in bnd for v in e.verts])
|
|
hist, edges = np.histogram(bz, bins=12)
|
|
print("BOUNDARY-EDGE z histogram (holes live here):")
|
|
for c, lo, hi in zip(hist, edges[:-1], edges[1:]):
|
|
if c:
|
|
print(f" z {lo:.3f}-{hi:.3f}: {c}")
|
|
bm.free()
|
|
|
|
# ---- shading kinks = the visible lines ----
|
|
nrm = np.empty(n_v * 3)
|
|
me.vertices.foreach_get("normal", nrm)
|
|
nrm = nrm.reshape(-1, 3)
|
|
ev = np.empty(len(me.edges) * 2, dtype=np.int32)
|
|
me.edges.foreach_get("vertices", ev)
|
|
ev = ev.reshape(-1, 2)
|
|
order = np.concatenate([ev[:, 0], ev[:, 1]])
|
|
nbr = np.concatenate([ev[:, 1], ev[:, 0]])
|
|
srt = np.argsort(order, kind="stable")
|
|
o_s, n_s = order[srt], nbr[srt]
|
|
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
|
|
cnt = np.maximum(np.diff(ptr), 1)
|
|
|
|
|
|
def nbr_mean(X):
|
|
acc = np.add.reduceat(X[n_s], ptr[:-1], axis=0)
|
|
empty = np.diff(ptr) == 0
|
|
acc[empty] = X[empty]
|
|
return acc / cnt[:, None]
|
|
|
|
|
|
N = nrm.copy()
|
|
for _ in range(5):
|
|
N = nbr_mean(N)
|
|
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
|
|
ang = np.degrees(np.arccos(np.clip((nrm * N).sum(axis=1), -1, 1)))
|
|
|
|
# signed offset from the locally-smooth surface: negative = groove, positive = ridge
|
|
sm = co.copy()
|
|
for _ in range(12):
|
|
sm = nbr_mean(sm)
|
|
dev = ((co - sm) * N).sum(axis=1) # metres, along the smooth normal
|
|
|
|
torso = (co[:, 2] > 0.28) & (co[:, 2] < 0.90)
|
|
for thr in (8.0, 12.0, 20.0):
|
|
k = torso & (ang > thr)
|
|
print(f"KINK >{thr:4.1f}deg : {k.sum():6d} verts "
|
|
f"(groove depth p05={np.percentile(dev[k],5)*1000:+.3f} mm, "
|
|
f"median={np.median(dev[k])*1000:+.3f} mm, "
|
|
f"p95={np.percentile(dev[k],95)*1000:+.3f} mm)" if k.sum() else f"KINK >{thr}: none")
|
|
|
|
kink = torso & (ang > 12.0)
|
|
if kink.sum():
|
|
hist, edges = np.histogram(co[kink, 2], bins=16)
|
|
print("KINK z histogram (the lines):")
|
|
for c, lo, hi in zip(hist, edges[:-1], edges[1:]):
|
|
if c > 20:
|
|
print(f" z {lo:.3f}-{hi:.3f}: {c:5d}")
|
|
|
|
# are kink verts topologically split? count how many sit on a boundary or have a
|
|
# near-duplicate vertex that is NOT an edge-neighbour (= two panels touching, unmerged)
|
|
from mathutils import Vector
|
|
from mathutils.kdtree import KDTree
|
|
kidx = np.nonzero(kink)[0]
|
|
sample = kidx[::max(1, len(kidx) // 4000)]
|
|
kd = KDTree(n_v)
|
|
for i in range(n_v):
|
|
kd.insert(Vector(co[i]), i)
|
|
kd.balance()
|
|
nbrs = [set() for _ in range(0)]
|
|
adj = {}
|
|
for a, b in ev:
|
|
adj.setdefault(a, set()).add(b)
|
|
adj.setdefault(b, set()).add(a)
|
|
split = 0
|
|
for i in sample:
|
|
for (_, j, d) in kd.find_range(Vector(co[i]), 0.0008):
|
|
if j != i and j not in adj.get(i, ()):
|
|
split += 1
|
|
break
|
|
print(f"SPLIT-PANEL test on {len(sample)} kink verts: {split} "
|
|
f"({100.0*split/max(len(sample),1):.1f}%) have an unmerged twin within 0.8 mm")
|
|
|
|
# =============================================================================
|
|
# 3. CLEAVAGE — concavity of the medial corridor
|
|
# =============================================================================
|
|
print("\n=== CLEAVAGE: medial corridor cross-sections y(x) ===")
|
|
front = co[:, 1] < 0
|
|
for z0 in np.arange(0.62, 0.745, 0.015):
|
|
row = []
|
|
for x0 in np.arange(-0.05, 0.0501, 0.005):
|
|
m = front & (np.abs(co[:, 0] - x0) < 0.0035) & (np.abs(co[:, 2] - z0) < 0.004)
|
|
row.append(co[m, 1].min() if m.sum() else np.nan)
|
|
row = np.array(row)
|
|
if np.isnan(row).all():
|
|
continue
|
|
# curvature of the y(x) profile at the sternum: second difference over 5 mm steps
|
|
mid = len(row) // 2
|
|
seg = row[max(0, mid - 3):mid + 4]
|
|
if len(seg) >= 3 and not np.isnan(seg).any():
|
|
d2 = (seg[:-2] - 2 * seg[1:-1] + seg[2:]) / (0.005 ** 2)
|
|
kmax = np.nanmax(d2)
|
|
rad = 1.0 / kmax if kmax > 1e-6 else float('inf')
|
|
print(f" z={z0:.3f} sternum y={row[mid]:+.4f} "
|
|
f"max concave curvature {kmax:8.1f} 1/m -> fillet radius "
|
|
f"{rad*1000:6.1f} mm" + (" <-- SHARP" if rad < 0.012 else ""))
|
|
|
|
print("\n=== CLEAVAGE: depth of the notch (breast apex y vs sternum y) ===")
|
|
for z0 in np.arange(0.62, 0.745, 0.015):
|
|
ms = front & (np.abs(co[:, 0]) < 0.004) & (np.abs(co[:, 2] - z0) < 0.004)
|
|
ma = front & (np.abs(np.abs(co[:, 0]) - 0.034) < 0.005) & (np.abs(co[:, 2] - z0) < 0.004)
|
|
if ms.sum() and ma.sum():
|
|
print(f" z={z0:.3f} sternum {co[ms,1].min():+.4f} apex {co[ma,1].min():+.4f} "
|
|
f"notch {(co[ms,1].min()-co[ma,1].min())*1000:+6.1f} mm")
|
|
|
|
# =============================================================================
|
|
# 2. DISCOLOURATION — repainted texels vs surrounding skin
|
|
# =============================================================================
|
|
def grab_images():
|
|
out = {}
|
|
for i in bpy.data.images:
|
|
nm = i.name.lower()
|
|
if "basecolor" in nm:
|
|
out["base"] = i
|
|
return out
|
|
|
|
|
|
def px(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)[:, :, :3].astype(np.float32), w, h
|
|
|
|
|
|
cur = grab_images()
|
|
if "base" not in cur:
|
|
print("\nDISCOLOUR: no basecolor image found; skipping")
|
|
else:
|
|
A, w, h = px(cur["base"])
|
|
log(f"current basecolor {w}x{h} '{cur['base'].name}'")
|
|
np.save(os.path.join(SCRATCH, "cur_base.npy"), A)
|
|
if ORIG and os.path.exists(ORIG):
|
|
bpy.ops.wm.open_mainfile(filepath=ORIG)
|
|
og = grab_images()
|
|
if "base" in og:
|
|
B, w2, h2 = px(og["base"])
|
|
log(f"original basecolor {w2}x{h2} '{og['base'].name}'")
|
|
if (w2, h2) == (w, h):
|
|
d = np.abs(A - B).max(axis=2)
|
|
mask = d > 0.02
|
|
print(f"\nDISCOLOUR: repainted texels = {int(mask.sum())} "
|
|
f"({100.0*mask.sum()/(w*h):.2f}% of atlas)")
|
|
|
|
def dil(m, k):
|
|
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
|
|
|
|
inner = mask & ~dil(~mask, 6) # 6 px in from the patch edge
|
|
ring = dil(mask, 10) & ~dil(mask, 2) # untouched skin just outside
|
|
if inner.any() and ring.any():
|
|
mi = A[inner].mean(axis=0)
|
|
mr = A[ring].mean(axis=0)
|
|
print(f" patch interior mean RGB {mi[0]:.4f} {mi[1]:.4f} {mi[2]:.4f}")
|
|
print(f" outside ring mean RGB {mr[0]:.4f} {mr[1]:.4f} {mr[2]:.4f}")
|
|
print(f" OFFSET (patch-ring) {mi[0]-mr[0]:+.4f} {mi[1]-mr[1]:+.4f} "
|
|
f"{mi[2]-mr[2]:+.4f} (luma {(mi.mean()-mr.mean()):+.4f})")
|
|
print(f" patch interior stddev {A[inner].std(axis=0)}")
|
|
print(f" ring stddev {A[ring].std(axis=0)}")
|
|
# per-region: split the mask into connected blobs and report the big ones
|
|
lab = np.zeros(mask.shape, dtype=np.int32)
|
|
cur_l = 0
|
|
ys, xs = np.nonzero(mask)
|
|
seen = np.zeros(mask.shape, dtype=bool)
|
|
from collections import deque
|
|
blobs = []
|
|
for y0, x0 in zip(ys, xs):
|
|
if seen[y0, x0]:
|
|
continue
|
|
cur_l += 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 < h and 0 <= xx < w and mask[yy, xx] and not seen[yy, xx]:
|
|
seen[yy, xx] = True
|
|
q.append((yy, xx))
|
|
if len(cells) > 2000:
|
|
blobs.append(cells)
|
|
print(f" {len(blobs)} patch blobs >2000 texels")
|
|
for bi, cells in enumerate(sorted(blobs, key=len, reverse=True)[:8]):
|
|
cy = np.array([c[0] for c in cells])
|
|
cx = np.array([c[1] for c in cells])
|
|
bm_ = np.zeros(mask.shape, dtype=bool)
|
|
bm_[cy, cx] = True
|
|
bin_ = bm_ & ~dil(~bm_, 5)
|
|
br_ = dil(bm_, 10) & ~dil(bm_, 2) & ~mask
|
|
if bin_.any() and br_.any():
|
|
a_ = A[bin_].mean(axis=0)
|
|
r_ = A[br_].mean(axis=0)
|
|
print(f" blob{bi}: {len(cells):7d} px uv~({cx.mean()/w:.3f},"
|
|
f"{cy.mean()/h:.3f}) offset {a_[0]-r_[0]:+.4f} "
|
|
f"{a_[1]-r_[1]:+.4f} {a_[2]-r_[2]:+.4f} luma "
|
|
f"{a_.mean()-r_.mean():+.4f}")
|
|
np.save(os.path.join(SCRATCH, "patch_mask.npy"), mask)
|
|
log(f"saved patch_mask.npy ({int(mask.sum())} texels)")
|
|
else:
|
|
print(f"DISCOLOUR: size mismatch {w}x{h} vs {w2}x{h2}")
|
|
print("DIAG_DONE")
|