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>
329 lines
13 KiB
Python
329 lines
13 KiB
Python
# Stage 39: the remaining speckle is GEOMETRY (confirmed by clay render, 38_clay.py) — small
|
|
# shards, slivers and one-vertex spikes on the hips, thighs and belly. Diagnose first, fix only
|
|
# what the numbers show.
|
|
#
|
|
# blender --background --python 39_despeckle.py -- <in.blend> <out.blend> [--apply] [out.glb]
|
|
#
|
|
# THE METRIC — and the one that did NOT work. Deviation from a locally smoothed copy of the
|
|
# surface was tried first and is useless at this density: with ~10 mm edges, three Laplacian
|
|
# passes erase real curvature, so |dev| > 1 mm flags 33% of the body — her hips and her toes score
|
|
# the same as a defect. Speckle is not "far from smooth", it is SHARP and ISOLATED. So:
|
|
# sharpness = dihedral angle across an edge (a flap folds back on itself; a hip does not)
|
|
# isolation = size of the connected cluster of sharp vertices (a real crease under the buttock
|
|
# runs for hundreds of vertices; a shard is a handful)
|
|
# Requiring both is what separates a defect from anatomy, and neither test alone does.
|
|
#
|
|
# WHY NOT DELETE AND FILL. Already tried twice in this lane and refused both times: the 830
|
|
# boundary edges sit in open chains of 3-5, i.e. dangling flaps, not perforations. Nothing to fill.
|
|
# Flattening a flap onto the surface it hangs off is the operation that actually applies, and it
|
|
# changes no topology, so the atlas and its textures stay valid.
|
|
#
|
|
# Vertex moves are clamped, so this cannot quietly restyle her.
|
|
import bpy, bmesh, sys, os, time
|
|
import numpy as np
|
|
|
|
argv = sys.argv[sys.argv.index("--") + 1:]
|
|
BLEND, OUT = argv[0], argv[1]
|
|
APPLY = "--apply" in argv
|
|
GLB = next((a for a in argv[2:] if a.lower().endswith(".glb")), "")
|
|
t0 = time.time()
|
|
SHARD_MAX = 60 # faces; the body is one component of ~65k, so this is unambiguous debris
|
|
SMOOTH_N = 3
|
|
MAX_MOVE_MM = 3.0
|
|
|
|
|
|
def log(m):
|
|
print(f"[spk {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_f = len(me.vertices), len(me.polygons)
|
|
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
|
|
UNITM = 1.777 / (co[:, 2].max() - co[:, 2].min())
|
|
MM = UNITM * 1000.0
|
|
log(f"in: {n_v}v {n_f}f 1 unit = {MM:.1f} mm")
|
|
|
|
ev = np.empty(len(me.edges) * 2, dtype=np.int32); me.edges.foreach_get("vertices", ev)
|
|
ev = ev.reshape(-1, 2)
|
|
o_ = np.concatenate([ev[:, 0], ev[:, 1]])
|
|
n_ = np.concatenate([ev[:, 1], ev[:, 0]])
|
|
srt = np.argsort(o_, kind="stable")
|
|
o_s, n_s = o_[srt], n_[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)
|
|
acc[np.diff(ptr) == 0] = X[np.diff(ptr) == 0]
|
|
return acc / cnt[:, None]
|
|
|
|
|
|
def deviation(P):
|
|
nrm = np.empty(n_v * 3); me.vertices.foreach_get("normal", nrm); nrm = nrm.reshape(-1, 3)
|
|
sm = P.copy()
|
|
for _ in range(SMOOTH_N):
|
|
sm = nbr_mean(sm)
|
|
return ((P - sm) * nrm).sum(axis=1), sm
|
|
|
|
|
|
SHARP_DEG = float(os.environ.get('SHARP_DEG', '70.0'))
|
|
CLUSTER_MAX = int(os.environ.get('CLUSTER_MAX', '14'))
|
|
|
|
|
|
def sharp_verts(deg_thresh):
|
|
"""vertices touching an edge whose two faces fold by more than deg_thresh"""
|
|
bm_ = bmesh.new(); bm_.from_mesh(me)
|
|
ang, vs = [], np.zeros(len(bm_.verts), dtype=bool)
|
|
for e in bm_.edges:
|
|
if len(e.link_faces) != 2:
|
|
continue
|
|
a = e.calc_face_angle_signed(None)
|
|
if a is None:
|
|
continue
|
|
d = abs(np.degrees(a))
|
|
ang.append(d)
|
|
if d > deg_thresh:
|
|
vs[e.verts[0].index] = True
|
|
vs[e.verts[1].index] = True
|
|
bm_.free()
|
|
return np.array(ang), vs
|
|
|
|
|
|
ang, _ = sharp_verts(1e9)
|
|
print("\n=== DIHEDRAL ANGLE ACROSS EDGES (degrees; a fold, not a curve) ===")
|
|
for p in (50, 90, 99, 99.5, 99.9):
|
|
print(f" p{p:<5} {np.percentile(ang, p):7.2f}")
|
|
print(f" max {ang.max():.2f}")
|
|
for t in (30, 50, 70, 90):
|
|
print(f" edges folding > {t}deg: {int((ang > t).sum()):6d} of {len(ang)}")
|
|
|
|
_, sharp = sharp_verts(SHARP_DEG)
|
|
print(f"\nvertices on a >{SHARP_DEG:.0f}deg fold: {int(sharp.sum())}")
|
|
|
|
# cluster the sharp vertices: anatomy runs long, defects are isolated
|
|
def speckle_mask(verbose=False):
|
|
"""sharp AND isolated. Recomputable, because deleting debris renumbers vertices — computing
|
|
this once up front and reusing it after a topology change indexed the wrong array."""
|
|
nv = len(me.vertices)
|
|
ev_ = np.empty(len(me.edges) * 2, dtype=np.int32); me.edges.foreach_get("vertices", ev_)
|
|
ev_ = ev_.reshape(-1, 2)
|
|
_, sharp_ = sharp_verts(SHARP_DEG)
|
|
adj_ = {}
|
|
for a_, b_ in ev_:
|
|
if sharp_[a_] and sharp_[b_]:
|
|
adj_.setdefault(a_, []).append(b_)
|
|
adj_.setdefault(b_, []).append(a_)
|
|
lab_ = -np.ones(nv, dtype=np.int64)
|
|
sizes_ = []
|
|
for s_ in np.nonzero(sharp_)[0]:
|
|
if lab_[s_] >= 0:
|
|
continue
|
|
stack, cells = [s_], []
|
|
lab_[s_] = len(sizes_)
|
|
while stack:
|
|
v_ = stack.pop()
|
|
cells.append(v_)
|
|
for w_ in adj_.get(v_, ()):
|
|
if lab_[w_] < 0:
|
|
lab_[w_] = len(sizes_)
|
|
stack.append(w_)
|
|
sizes_.append(len(cells))
|
|
sizes_ = np.array(sizes_) if sizes_ else np.array([0])
|
|
small_ = np.array([lab_[i] >= 0 and sizes_[lab_[i]] <= CLUSTER_MAX for i in range(nv)])
|
|
if verbose:
|
|
print("")
|
|
print(f"vertices on a >{SHARP_DEG:.0f}deg fold: {int(sharp_.sum())}")
|
|
print(f"sharp clusters: {len(sizes_)} (sizes: p50 {np.percentile(sizes_,50):.0f}, "
|
|
f"p90 {np.percentile(sizes_,90):.0f}, max {sizes_.max()})")
|
|
print(f"SPECKLE = sharp AND in a cluster of <= {CLUSTER_MAX} verts: {int(small_.sum())} "
|
|
f"verts in {int((sizes_ <= CLUSTER_MAX).sum())} clusters")
|
|
print(f" (kept as anatomy: {int(sharp_.sum() - small_.sum())} verts in "
|
|
f"{int((sizes_ > CLUSTER_MAX).sum())} long folds)")
|
|
return small_
|
|
|
|
|
|
small = speckle_mask(verbose=True)
|
|
spike = small
|
|
if spike.any():
|
|
z = (co[:, 2] - co[:, 2].min()) / (co[:, 2].max() - co[:, 2].min())
|
|
hist, edges = np.histogram(z[spike], bins=10, range=(0, 1))
|
|
print("\nheight distribution of the speckle (0=feet, 1=head):")
|
|
for c, lo, hi in zip(hist, edges[:-1], edges[1:]):
|
|
if c:
|
|
print(f" {lo:.1f}-{hi:.1f}: {c:5d}")
|
|
|
|
# stray components
|
|
bm = bmesh.new(); bm.from_mesh(me)
|
|
bm.verts.ensure_lookup_table()
|
|
parent = np.arange(n_v, dtype=np.int64)
|
|
|
|
|
|
def find(x):
|
|
r = x
|
|
while parent[r] != r:
|
|
r = parent[r]
|
|
while parent[x] != r:
|
|
parent[x], x = r, parent[x]
|
|
return r
|
|
|
|
|
|
for a, b in ev:
|
|
ra, rb = find(a), find(b)
|
|
if ra != rb:
|
|
parent[rb] = ra
|
|
roots = np.array([find(i) for i in range(n_v)])
|
|
_, comp, sizes = np.unique(roots, return_inverse=True, return_counts=True)
|
|
big = np.argmax(sizes)
|
|
debris = np.nonzero(sizes < SHARD_MAX)[0]
|
|
n_debris_v = int(sizes[debris].sum()) if len(debris) else 0
|
|
print(f"\ncomponents: {len(sizes)} largest {sizes[big]} v "
|
|
f"debris (<{SHARD_MAX} faces worth): {len(debris)} components / {n_debris_v} verts")
|
|
slivers = [f for f in bm.faces if f.calc_area() * MM * MM < 0.02]
|
|
print(f"slivers (<0.02 mm2): {len(slivers)}")
|
|
bm.free()
|
|
|
|
if not APPLY:
|
|
print("DIAGNOSE ONLY — rerun with --apply to fix")
|
|
print("DESPECKLE_DONE")
|
|
sys.exit(0)
|
|
|
|
# ---------------------------------------------------------------- fix
|
|
# 1. delete debris components outright
|
|
if n_debris_v:
|
|
keep = comp == big
|
|
bm = bmesh.new(); bm.from_mesh(me)
|
|
bm.verts.ensure_lookup_table()
|
|
kill = [bm.verts[i] for i in range(n_v) if sizes[comp[i]] < SHARD_MAX]
|
|
bmesh.ops.delete(bm, geom=kill, context='VERTS')
|
|
bm.to_mesh(me)
|
|
bm.free()
|
|
log(f"deleted {len(kill)} debris verts")
|
|
n_v = len(me.vertices)
|
|
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
|
|
ev = np.empty(len(me.edges) * 2, dtype=np.int32); me.edges.foreach_get("vertices", ev)
|
|
ev = ev.reshape(-1, 2)
|
|
o_ = np.concatenate([ev[:, 0], ev[:, 1]]); n_ = np.concatenate([ev[:, 1], ev[:, 0]])
|
|
srt = np.argsort(o_, kind="stable"); o_s, n_s = o_[srt], n_[srt]
|
|
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
|
|
cnt = np.maximum(np.diff(ptr), 1)
|
|
spike = speckle_mask()
|
|
log(f"speckle mask rebuilt after debris deletion: {int(spike.sum())} verts")
|
|
|
|
# 2. flatten spikes onto the local surface, clamped, re-measuring each round
|
|
co0 = co.copy()
|
|
for it in range(6):
|
|
_, sm = deviation(co)
|
|
if it > 0:
|
|
_, sh2 = sharp_verts(SHARP_DEG)
|
|
spike = spike & sh2 # stop touching anything that is no longer folded
|
|
if not spike.any():
|
|
log(f"round {it}: no folded speckle left")
|
|
break
|
|
tgt = co.copy()
|
|
tgt[spike] = sm[spike]
|
|
step = tgt - co
|
|
n_step = np.linalg.norm(step, axis=1)
|
|
lim = (MAX_MOVE_MM / MM)
|
|
too = n_step > lim
|
|
step[too] *= (lim / n_step[too])[:, None]
|
|
co = co + step
|
|
me.vertices.foreach_set("co", co.reshape(-1))
|
|
me.update()
|
|
log(f"round {it}: {int(spike.sum())} spikes, moved p99 "
|
|
f"{np.percentile(n_step[spike], 99)*MM:.3f} mm")
|
|
|
|
# 3. collapse whatever refused to flatten. A FOLDED flap cannot be smoothed away: its Laplacian
|
|
# target is computed from neighbours that include the flap itself, so the target sits inside the
|
|
# fold and the iteration stalls (it did — 891 spikes fell to 335 and then stopped). Merging each
|
|
# stubborn cluster to a single point removes the fold outright instead of trying to relax it.
|
|
_, sh3 = sharp_verts(SHARP_DEG)
|
|
if sh3.any():
|
|
adj2 = {}
|
|
for a, b in ev:
|
|
if sh3[a] and sh3[b]:
|
|
adj2.setdefault(a, []).append(b)
|
|
adj2.setdefault(b, []).append(a)
|
|
lab2 = -np.ones(n_v, dtype=np.int64)
|
|
groups = []
|
|
for s in np.nonzero(sh3)[0]:
|
|
if lab2[s] >= 0:
|
|
continue
|
|
stack, cells = [s], []
|
|
lab2[s] = len(groups)
|
|
while stack:
|
|
v = stack.pop()
|
|
cells.append(v)
|
|
for w in adj2.get(v, ()):
|
|
if lab2[w] < 0:
|
|
lab2[w] = len(groups)
|
|
stack.append(w)
|
|
groups.append(cells)
|
|
tight = [g for g in groups if len(g) <= CLUSTER_MAX]
|
|
log(f"collapsing {len(tight)} stubborn clusters ({sum(len(g) for g in tight)} verts)")
|
|
# DISSOLVE, do not merge. Collapsing a cluster to a single point leaves a fan vertex in the
|
|
# middle of where the flap was; on a ring that is not planar that vertex is itself a new
|
|
# spike, and measured at 55 deg the merge made folds >70 deg WORSE (361 -> 444). Dissolving
|
|
# removes the offending vertices outright and lets the surrounding ring close over the hole,
|
|
# which is what "remove a dangling flap" actually means.
|
|
bm = bmesh.new(); bm.from_mesh(me)
|
|
bm.verts.ensure_lookup_table()
|
|
victims = [bm.verts[i] for g in tight for i in g]
|
|
victims = [v for v in victims if v.is_valid]
|
|
try:
|
|
bmesh.ops.dissolve_verts(bm, verts=victims)
|
|
except Exception as e:
|
|
log(f" dissolve_verts failed: {e}")
|
|
bmesh.ops.triangulate(bm, faces=[f for f in bm.faces if len(f.verts) > 3])
|
|
bmesh.ops.dissolve_degenerate(bm, dist=1e-6, edges=bm.edges)
|
|
bm.to_mesh(me)
|
|
bm.free()
|
|
me.update()
|
|
n_v = len(me.vertices)
|
|
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
|
|
ev = np.empty(len(me.edges) * 2, dtype=np.int32); me.edges.foreach_get("vertices", ev)
|
|
ev = ev.reshape(-1, 2)
|
|
o_ = np.concatenate([ev[:, 0], ev[:, 1]]); n_ = np.concatenate([ev[:, 1], ev[:, 0]])
|
|
srt = np.argsort(o_, kind="stable"); o_s, n_s = o_[srt], n_[srt]
|
|
ptr = np.searchsorted(o_s, np.arange(n_v + 1))
|
|
cnt = np.maximum(np.diff(ptr), 1)
|
|
co0 = co0[:n_v] if len(co0) > n_v else np.pad(co0, ((0, n_v - len(co0)), (0, 0)))
|
|
ang4, sh4 = sharp_verts(SHARP_DEG)
|
|
log(f"after collapse: {n_v}v {len(me.polygons)}f, "
|
|
f"edges folding >{SHARP_DEG:.0f}deg: {int((ang4 > SHARP_DEG).sum())} "
|
|
f"(was {int((ang > SHARP_DEG).sum())})")
|
|
for t in (30, 50, 70, 90):
|
|
print(f" edges folding > {t}deg: {int((ang4 > t).sum()):6d} (before {int((ang > t).sum())})")
|
|
|
|
# Only meaningful if the collapse stage did not renumber the vertices. It used to print
|
|
# regardless and reported nonsense (p50 243 mm) by differencing two arrays whose indices no
|
|
# longer refer to the same points — a number that looks alarming and means nothing.
|
|
if len(co) == len(co0):
|
|
moved = np.linalg.norm(co - co0, axis=1) * MM
|
|
print(f"\nTOTAL MOVEMENT: {int((moved>0.01).sum())} verts moved, "
|
|
f"p50 {np.percentile(moved[moved>0.01], 50) if (moved>0.01).any() else 0:.3f} mm, "
|
|
f"max {moved.max():.3f} mm")
|
|
else:
|
|
print(f"\nTOTAL MOVEMENT: not comparable — the collapse stage renumbered vertices "
|
|
f"({len(co0)} -> {len(co)}). Judge from the fold counts above.")
|
|
dev2, _ = deviation(co)
|
|
d2 = dev2 * MM
|
|
print("AFTER:")
|
|
for t in (0.5, 1.0, 1.5, 2.0, 3.0):
|
|
print(f" |dev| > {t:.1f} mm: {int((np.abs(d2)>t).sum()):6d} verts")
|
|
print(f" height unchanged: {co[:,2].max()-co[:,2].min():.5f} units")
|
|
|
|
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
|
log(f"WROTE {OUT}")
|
|
if GLB:
|
|
for o in bpy.data.objects:
|
|
o.select_set(o is ob)
|
|
bpy.context.view_layer.objects.active = ob
|
|
bpy.ops.export_scene.gltf(filepath=os.path.abspath(GLB), export_format='GLB',
|
|
use_selection=True, export_image_format='AUTO',
|
|
export_jpeg_quality=95, export_yup=True, export_apply=False)
|
|
log(f"EXPORTED {GLB}")
|
|
print("DESPECKLE_DONE")
|