feat(characters): ship lena_leafbikini_base_v01 as female Ariki default
Lena with the leaf bikini sculpted into the mesh — a separate character descending from her own Tripo original, not a lena_base_v02. Full-res (1,029,360 v) at 1.777 m, now Ariki_Female_QuatSkin.glb + LOD1. First nearest-surface graft in the rig lane: AccuRig had to be fed a 20:1 decimated bait, so the index-exact graft the two earlier ships used was impossible. The bait recovers AccuRig's 9.346 mm offset in closed form, which makes it a required input forever — copied into the ship folder, and rig-work's "disposable" rule amended to say so. Mesh moved 0 mm, 0 unweighted verts. lena_base_v01 goes superseded, not rolled-back: she lost the default slot but Ariki_Female_QuatSkin_Nude.glb is untouched, so per rule 9 her folder stays put. Also lands the leaf-cut lane (work/lena_leafbikini): hue-keyed seam detection that removes the leaf shell and leaves both holes open by request, registered as a lane milestone with its sha256. Corrects the rig-work trap note — baits do carry embedded textures; it is AccuRig that strips them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
# lena_leafbikini lane, stage 02: PROBE — what exactly are the leaves?
|
||||
#
|
||||
# blender --background --factory-startup --python 02_probe_leaves.py -- <pristine.glb> [outdir]
|
||||
#
|
||||
# Before anything is cut, this answers the two questions that decide the method:
|
||||
#
|
||||
# 1. Are the leaves PAINTED or SCULPTED? Lena's underwear on the game body turned out to be
|
||||
# painted onto the body skin with no geometry of its own (characters/work/lena/README,
|
||||
# tools/make_lena_nude_body.py finding 1) — deleting it opened a hole because the garment
|
||||
# WAS the skin. If the leaves are the same, "remove at the seam" means a colour-keyed face
|
||||
# delete and nothing more. If they are real shells sitting proud of the body, the seam is a
|
||||
# geometric crease and the colour key is only a coarse pre-filter.
|
||||
# 2. Where is the seam? Reported here as the distribution of per-vertex proudness (signed
|
||||
# distance from a heavily smoothed reference surface) inside vs outside the green key.
|
||||
#
|
||||
# Everything is numpy over foreach_get buffers: this mesh is ~1.03M verts and a per-vertex
|
||||
# Python loop over it costs minutes.
|
||||
import bpy, sys, os, time
|
||||
import numpy as np
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
GLB = os.path.abspath(argv[0])
|
||||
OUT = os.path.abspath(argv[1]) if len(argv) > 1 else os.path.dirname(GLB)
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
t0 = time.time()
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[probe {time.time()-t0:6.1f}s] {m}", flush=True)
|
||||
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=GLB)
|
||||
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
|
||||
log(f"objects: {[(o.name, o.type) for o in bpy.data.objects]}")
|
||||
for o in meshes:
|
||||
log(f" MESH '{o.name}': {len(o.data.vertices)}v {len(o.data.polygons)}f "
|
||||
f"uv={[l.name for l in o.data.uv_layers]} mats={[m.name for m in o.data.materials if m]}")
|
||||
body = max(meshes, key=lambda o: len(o.data.vertices))
|
||||
me = body.data
|
||||
n = len(me.vertices)
|
||||
|
||||
# ── geometry ────────────────────────────────────────────────────────────────────────────────
|
||||
co = np.empty(n * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
|
||||
M = np.array(body.matrix_world)
|
||||
W = co @ M[:3, :3].T + M[:3, 3]
|
||||
lo, hi = W.min(0), W.max(0)
|
||||
log(f"bbox min={np.round(lo,4)} max={np.round(hi,4)} size={np.round(hi-lo,4)}")
|
||||
log(f"height(z) {hi[2]-lo[2]:.4f} -> 1 unit = {1.777/(hi[2]-lo[2]):.4f} of a 1.777 m body")
|
||||
|
||||
# ── textures ────────────────────────────────────────────────────────────────────────────────
|
||||
imgs = {}
|
||||
for mat in [m for m in me.materials if m]:
|
||||
for nd in mat.node_tree.nodes:
|
||||
if nd.type == 'TEX_IMAGE' and nd.image:
|
||||
tgt = [l.to_socket.name for o in nd.outputs for l in o.links]
|
||||
log(f" tex '{nd.image.name}' {tuple(nd.image.size)} cs={nd.image.colorspace_settings.name} -> {tgt}")
|
||||
imgs[nd.image.name] = nd.image
|
||||
|
||||
# base colour = the image feeding Base Color
|
||||
base = None
|
||||
for mat in [m for m in me.materials if m]:
|
||||
bsdf = next((x for x in mat.node_tree.nodes if x.type == 'BSDF_PRINCIPLED'), None)
|
||||
if not bsdf:
|
||||
continue
|
||||
lnk = bsdf.inputs["Base Color"].links
|
||||
if lnk:
|
||||
nd = lnk[0].from_node
|
||||
while nd.type != 'TEX_IMAGE' and nd.inputs:
|
||||
up = [i for i in nd.inputs if i.links]
|
||||
if not up:
|
||||
break
|
||||
nd = up[0].links[0].from_node
|
||||
if nd.type == 'TEX_IMAGE':
|
||||
base = nd.image
|
||||
if base is None:
|
||||
raise SystemExit("[probe] FATAL: no base-colour image found")
|
||||
log(f"base colour image: '{base.name}' {tuple(base.size)}")
|
||||
|
||||
# ── per-vertex UV (first loop wins), then sample the albedo ─────────────────────────────────
|
||||
nl = len(me.loops)
|
||||
lv = np.empty(nl, dtype=np.int32); me.loops.foreach_get("vertex_index", lv)
|
||||
uv = np.empty(nl * 2); me.uv_layers.active.data.foreach_get("uv", uv); uv = uv.reshape(-1, 2)
|
||||
vuv = np.zeros((n, 2))
|
||||
vuv[lv[::-1]] = uv[::-1] # reversed scatter -> first loop of each vert wins
|
||||
w, h = base.size
|
||||
buf = np.empty(w * h * 4, dtype=np.float32); base.pixels.foreach_get(buf)
|
||||
px = buf.reshape(h, w, 4)[:, :, :3]
|
||||
del buf
|
||||
# bpy-imported UVs are already v-flipped by the importer (see memory: gltf-uv-flip-vs-blender-images)
|
||||
xi = np.clip((vuv[:, 0] * (w - 1)).astype(np.int32), 0, w - 1)
|
||||
yi = np.clip((vuv[:, 1] * (h - 1)).astype(np.int32), 0, h - 1)
|
||||
C = px[yi, xi] # linear RGB per vertex
|
||||
del px
|
||||
log(f"sampled albedo for {n} verts from {w}x{h}")
|
||||
|
||||
# linear -> sRGB for a colour key that matches what the eye/Tripo saw
|
||||
def to_srgb(x):
|
||||
return np.where(x <= 0.0031308, x * 12.92, 1.055 * np.maximum(x, 0) ** (1 / 2.4) - 0.055)
|
||||
|
||||
|
||||
S = np.clip(to_srgb(C), 0, 1)
|
||||
R, G, B = S[:, 0], S[:, 1], S[:, 2]
|
||||
mx, mn = S.max(1), S.min(1)
|
||||
sat = np.where(mx > 1e-5, (mx - mn) / np.maximum(mx, 1e-5), 0.0)
|
||||
# green dominance: G is the max channel and beats both others
|
||||
gdom = (G - np.maximum(R, B))
|
||||
log(f"albedo sRGB: mean R{R.mean():.3f} G{G.mean():.3f} B{B.mean():.3f} sat mean {sat.mean():.3f}")
|
||||
for thr in (0.0, 0.02, 0.05, 0.08, 0.12, 0.20):
|
||||
m = gdom > thr
|
||||
log(f" G-dominance > {thr:.2f}: {m.sum():7d} verts ({100*m.sum()/n:5.2f}%)"
|
||||
+ (f" z {W[m,2].min():.3f}..{W[m,2].max():.3f}" if m.any() else ""))
|
||||
|
||||
# ── proudness: signed offset from a smoothed reference surface ─────────────────────────────
|
||||
# Adjacency over POSITION-WELDED points (the importer splits every UV seam; see nude-body
|
||||
# finding 5). Built as a flat CSR from the edge list so smoothing is pure numpy.
|
||||
key = np.round(W, 6)
|
||||
_, inv = np.unique(key, axis=0, return_inverse=True)
|
||||
ng = inv.max() + 1
|
||||
log(f"welded: {n} verts -> {ng} unique positions ({n-ng} seam duplicates)")
|
||||
ev = np.empty(len(me.edges) * 2, dtype=np.int32); me.edges.foreach_get("vertices", ev)
|
||||
ea, eb = inv[ev[0::2]], inv[ev[1::2]]
|
||||
keep = ea != eb
|
||||
ea, eb = ea[keep], eb[keep]
|
||||
src = np.concatenate([ea, eb]); dst = np.concatenate([eb, ea])
|
||||
order = np.argsort(src, kind='stable')
|
||||
src, dst = src[order], dst[order]
|
||||
cnt = np.bincount(src, minlength=ng)
|
||||
ptr = np.concatenate([[0], np.cumsum(cnt)])
|
||||
cnt_safe = np.maximum(cnt, 1)
|
||||
|
||||
P = np.zeros((ng, 3)); np.add.at(P, inv, W); P /= np.bincount(inv, minlength=ng)[:, None]
|
||||
|
||||
|
||||
def nbr_mean(X):
|
||||
s = np.add.reduceat(X[dst], ptr[:-1], axis=0)
|
||||
s[cnt == 0] = X[cnt == 0]
|
||||
return s / cnt_safe[:, None]
|
||||
|
||||
|
||||
Q = P.copy()
|
||||
for _ in range(60): # heavy Taubin: sheds the leaves, keeps the body
|
||||
Q += 0.55 * (nbr_mean(Q) - Q)
|
||||
Q += -0.58 * (nbr_mean(Q) - Q)
|
||||
# reference normal from the smoothed surface, via the vertex-normal buffer of the ORIGINAL
|
||||
vn = np.empty(n * 3); me.vertices.foreach_get("normal", vn); vn = vn.reshape(-1, 3)
|
||||
N = np.zeros((ng, 3)); np.add.at(N, inv, vn)
|
||||
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
|
||||
proud_g = np.einsum('ij,ij->i', P - Q, N)
|
||||
proud = proud_g[inv]
|
||||
mm = 1000.0 * 1.777 / (hi[2] - lo[2]) # units -> mm on a 1.777 m body
|
||||
log(f"proudness (mm, body-scaled): mean {proud.mean()*mm:+.2f} p50 {np.median(proud)*mm:+.2f} "
|
||||
f"p99 {np.percentile(proud,99)*mm:+.2f} max {proud.max()*mm:+.2f}")
|
||||
for thr in (0.05, 0.12):
|
||||
m = gdom > thr
|
||||
if m.sum() < 100:
|
||||
continue
|
||||
log(f" green(G-dom>{thr}): proud p50 {np.median(proud[m])*mm:+.2f} mm "
|
||||
f"p90 {np.percentile(proud[m],90)*mm:+.2f} mm")
|
||||
log(f" skin (G-dom<=0 ): proud p50 {np.median(proud[~(gdom>0)])*mm:+.2f} mm "
|
||||
f"p90 {np.percentile(proud[~(gdom>0)],90)*mm:+.2f} mm")
|
||||
break
|
||||
|
||||
np.savez_compressed(os.path.join(OUT, "probe_leaves.npz"),
|
||||
gdom=gdom.astype(np.float32), sat=sat.astype(np.float32),
|
||||
proud=proud.astype(np.float32), W=W.astype(np.float32),
|
||||
inv=inv.astype(np.int32))
|
||||
log(f"WROTE {os.path.join(OUT, 'probe_leaves.npz')}")
|
||||
Reference in New Issue
Block a user