Files
animation/characters/work/lena_leafbikini/02_probe_leaves.py
T

169 lines
8.0 KiB
Python
Raw Normal View History

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