Files
animation/characters/work/lena_leafbikini/04_leaf_mask.py
T
jeremy eae8327811 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>
2026-08-13 16:09:45 -07:00

284 lines
14 KiB
Python

# lena_leafbikini lane, stage 04: build the LEAF MASK and prove it before anything is cut.
#
# blender --background --factory-startup --python 04_leaf_mask.py -- <pristine.glb> <renderdir>
# [--hue-lo 52] [--hue-hi 170] [--sat-min 0.10] [--val-max 0.90] [--z-max 0.85]
# [--min-comp 200] [--close 6] [--grow 2] [--no-render]
#
# The mask lands at leaf_mask.npz beside this script, NOT in <renderdir>: renderdir is a
# review/ dir and those are gitignored scratch, while the mask is a KEEP-tier lane input
# (.agents/rules/working-files.md) that stage 05 consumes.
#
# WHAT THE LEAVES ACTUALLY ARE (stage 02 probe + stage 03 clay renders):
# * REAL GEOMETRY, not paint. The clay render shows every leaf, curled tip and hip vine with
# all materials stripped — so this is the OPPOSITE case to Lena's game-body underwear, which
# was painted onto the skin (tools/make_lena_nude_body.py, finding 1). There the fix was to
# re-fair a rim crease; here there is a solid shell to delete.
# * ONE WELDED SHELL with the body. Tripo emitted a single 1,029,360 v / 1,992,503 f mesh, so
# the leaves are not a separable object, material or UV island — they are a bulge in the body
# surface. Nothing can be selected "by object"; the seam has to be found.
# * The seam is a SHARP RIM. Where a leaf meets skin the surface folds back on itself, so the
# boundary is simultaneously a colour edge (green -> beige) and a crease.
#
# WHY THE KEY IS HUE, NOT "GREENNESS". Stage 02 keyed on G - max(R,B) and topped out at 0.20:
# Tripo painted these leaves a dark, desaturated olive (sRGB ~0.27/0.35/0.20), so the channel
# gap is only ~0.05-0.09 and any threshold that catches the leaf also catches shadow noise on
# skin. Hue separates them completely instead of marginally — skin sits at ~20-30 deg (orange),
# leaf at ~80-110 deg (green) — and hue is invariant to exactly the thing that ruins the channel
# gap here, which is how dark the pixel is.
#
# The mask is then repaired ON THE MESH, not in texture space: small components dropped (JPEG
# speckle), holes closed (leaf highlights that blow out to near-white lose their hue), and grown
# by one ring so the cut lands just outside the rim rather than just inside it. Erring outward is
# deliberate: a hole one ring too big is invisible, a leftover leaf stub is not.
import bpy, sys, os, time, argparse
import numpy as np
from collections import deque
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser()
ap.add_argument("glb")
ap.add_argument("outdir")
ap.add_argument("--hue-lo", type=float, default=52.0)
ap.add_argument("--hue-hi", type=float, default=170.0)
ap.add_argument("--sat-min", type=float, default=0.10)
ap.add_argument("--val-max", type=float, default=0.90,
help="hue alone separates olive leaf from beige skin; this only rejects pixels "
"so blown out that their hue is noise")
ap.add_argument("--z-max", type=float, default=0.85,
help="fraction of body height above which the key is ignored — her irises and "
"eyebrows are green too, and they are not leaves")
ap.add_argument("--min-comp", type=int, default=200)
ap.add_argument("--close", type=int, default=6, help="ring radius of the morphological close")
ap.add_argument("--grow", type=int, default=2, help="final dilation, in rings")
ap.add_argument("--no-render", action="store_true")
A = ap.parse_args(argv)
GLB, OUT = os.path.abspath(A.glb), os.path.abspath(A.outdir)
os.makedirs(OUT, exist_ok=True)
t0 = time.time()
def log(m):
print(f"[mask {time.time()-t0:6.1f}s] {m}", flush=True)
# =============================================================================================
# load + welded adjacency
# =============================================================================================
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=GLB)
body = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
me = body.data
n = len(me.vertices)
log(f"'{body.name}' {n}v {len(me.polygons)}f")
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]
H = W[:, 2].max() - W[:, 2].min()
MM = 1000.0 * 1.777 / H # units -> mm at final 1.777 m body scale
# The importer splits every UV seam into separate Blender vertices, so mesh adjacency is
# shattered along seams (nude-body finding 5). Every ring operation below runs on the
# POSITION-WELDED graph or it would leak holes along the seams.
_, inv = np.unique(np.round(W, 6), axis=0, return_inverse=True)
inv = inv.astype(np.int64)
ng = int(inv.max()) + 1
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]]
k = ea != eb
src = np.concatenate([ea[k], eb[k]]); dst = np.concatenate([eb[k], ea[k]])
o = np.argsort(src, kind='stable'); src, dst = src[o], dst[o]
cnt = np.bincount(src, minlength=ng)
ptr = np.concatenate([[0], np.cumsum(cnt)])
log(f"welded {n} -> {ng} points, {len(dst)//2} undirected edges")
def ring(m, k=1):
"""Dilate a boolean over welded adjacency by k rings."""
out = m.copy()
for _ in range(k):
hit = np.add.reduceat(out[dst].astype(np.int32), ptr[:-1]) > 0
hit[cnt == 0] = False
out = out | hit
return out
def shrink(m, k=1):
return ~ring(~m, k)
def components(m):
"""Connected components of a boolean over welded adjacency (exact BFS; the mask is small)."""
lab = np.full(ng, -1, dtype=np.int64)
comps = []
for s in np.nonzero(m)[0]:
if lab[s] >= 0:
continue
cid = len(comps)
q = deque([s]); lab[s] = cid; size = 0
while q:
c = q.popleft(); size += 1
for j in range(ptr[c], ptr[c + 1]):
nb = dst[j]
if m[nb] and lab[nb] < 0:
lab[nb] = cid; q.append(nb)
comps.append(size)
return lab, np.array(comps)
# =============================================================================================
# albedo -> HSV, per welded point
# =============================================================================================
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)
lnk = bsdf and bsdf.inputs["Base Color"].links
if lnk:
nd = lnk[0].from_node
while nd.type != 'TEX_IMAGE':
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("[mask] FATAL: no base-colour image")
nl = len(me.loops)
lv = np.empty(nl, dtype=np.int32); me.loops.foreach_get("vertex_index", lv)
uvb = np.empty(nl * 2); me.uv_layers.active.data.foreach_get("uv", uvb); uvb = uvb.reshape(-1, 2)
vuv = np.zeros((n, 2))
vuv[lv[::-1]] = uvb[::-1] # first loop of each vertex 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].copy(); del buf
# bpy-imported UVs are already v-flipped by the importer (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].astype(np.float64); del px
S = np.clip(np.where(C <= 0.0031308, C * 12.92, 1.055 * np.maximum(C, 0) ** (1 / 2.4) - 0.055), 0, 1)
R, G, B = S[:, 0], S[:, 1], S[:, 2]
mx = S.max(1); mn = S.min(1); d = mx - mn
hue = np.zeros(n)
nz = d > 1e-6
im = np.argmax(S, axis=1)
sel = nz & (im == 0); hue[sel] = 60 * (((G[sel] - B[sel]) / d[sel]) % 6)
sel = nz & (im == 1); hue[sel] = 60 * ((B[sel] - R[sel]) / d[sel] + 2)
sel = nz & (im == 2); hue[sel] = 60 * ((R[sel] - G[sel]) / d[sel] + 4)
sat = np.where(mx > 1e-6, d / np.maximum(mx, 1e-6), 0.0)
log("hue histogram (all verts, 20 deg bins):")
hh, _ = np.histogram(hue, bins=18, range=(0, 360))
log(" " + " ".join(f"{i*20:3d}:{c*100.0/n:5.2f}%" for i, c in enumerate(hh) if c))
zf = (W[:, 2] - W[:, 2].min()) / H
raw = (hue >= A.hue_lo) & (hue <= A.hue_hi) & (sat >= A.sat_min) & (mx <= A.val_max)
log(f"hue key [{A.hue_lo},{A.hue_hi}] sat>={A.sat_min} val<={A.val_max}: {raw.sum()} verts "
f"({100*raw.sum()/n:.2f}%) z {zf[raw].min():.3f}..{zf[raw].max():.3f} of height")
head = raw & (zf > A.z_max)
raw &= ~head
log(f"head gate z<={A.z_max}: dropped {head.sum()} verts (irises/eyebrows)")
# per-welded-point: a point is leaf if ANY of its seam copies keyed (a seam copy can sample the
# far side of a texture chart boundary)
m = np.zeros(ng, dtype=bool)
np.logical_or.at(m, inv, raw)
# =============================================================================================
# repair the mask on the mesh
# =============================================================================================
lab, sizes = components(m)
if len(sizes):
log(f"components: {len(sizes)}, largest {sorted(sizes)[-8:]}")
m &= np.isin(lab, np.nonzero(sizes >= A.min_comp)[0])
log(f" after min-comp {A.min_comp}: {m.sum()} points in {(sizes>=A.min_comp).sum()} comps")
if A.close:
m = shrink(ring(m, A.close), A.close) # close: fill specular blowouts inside a leaf
log(f"after close({A.close}): {m.sum()} points")
# any hole left inside the mask is a mask hole, not a real skin island: fill enclosed holes by
# dropping small components of the COMPLEMENT that do not touch the rest of the body
hlab, hsizes = components(~m)
if len(hsizes):
big = int(np.argmax(hsizes)) # the body itself
fill = (~m) & (hlab >= 0) & (hlab != big)
small = np.isin(hlab, np.nonzero(hsizes < 5000)[0]) & fill
if small.any():
m |= small
log(f"filled {small.sum()} enclosed hole points ({(hsizes<5000).sum()-0} small comps)")
if A.grow:
m = ring(m, A.grow)
log(f"after grow({A.grow}): {m.sum()} points")
lab2, sizes2 = components(m)
log(f"FINAL mask: {m.sum()} welded points ({100*m.sum()/ng:.2f}%), {len(sizes2)} islands, "
f"sizes {sorted(sizes2)[::-1][:10]}")
vm = m[inv]
log(f" -> {vm.sum()} mesh verts; z {zf[vm].min():.3f}..{zf[vm].max():.3f} of height, "
f"|x| max {np.abs(W[vm,0]).max()/H:.3f}")
for cid in np.argsort(sizes2)[::-1][:8]:
sel = lab2 == cid
zs = zf[sel[inv]]
log(f" island {int(sizes2[cid]):7d} pts z {zs.min():.3f}..{zs.max():.3f}")
MASK_OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "leaf_mask.npz")
np.savez_compressed(MASK_OUT,
mask=m, inv=inv.astype(np.int32), hue=hue.astype(np.float32),
sat=sat.astype(np.float32), val=mx.astype(np.float32))
log(f"WROTE {MASK_OUT}")
# =============================================================================================
# prove it: bake the mask to vertex colour and render
# =============================================================================================
if not A.no_render:
import math
from mathutils import Vector
lay = me.color_attributes.new(name="LeafMask", type='FLOAT_COLOR', domain='POINT')
colv = np.zeros((n, 4)); colv[:, 3] = 1.0
colv[:, 0] = np.where(vm, 1.0, 0.45)
colv[:, 1] = np.where(vm, 0.05, 0.44)
colv[:, 2] = np.where(vm, 0.05, 0.42)
lay.data.foreach_set("color", colv.ravel())
me.materials.clear()
mat = bpy.data.materials.new("MaskDbg"); mat.use_nodes = True
nt = mat.node_tree
vc = nt.nodes.new("ShaderNodeVertexColor"); vc.layer_name = "LeafMask"
bsdf = next(x for x in nt.nodes if x.type == 'BSDF_PRINCIPLED')
nt.links.new(vc.outputs["Color"], bsdf.inputs["Base Color"])
bsdf.inputs["Roughness"].default_value = 0.5
me.materials.append(mat)
wl = bpy.data.worlds.new("W"); wl.color = (0.20, 0.20, 0.22)
bpy.context.scene.world = wl
scn = bpy.context.scene
scn.render.engine = 'BLENDER_EEVEE' if bpy.app.version >= (4, 2) else 'BLENDER_EEVEE_NEXT'
scn.render.resolution_x = scn.render.resolution_y = 1000
cam = bpy.data.objects.new("Cam", bpy.data.cameras.new("Cam")); cam.data.lens = 85
bpy.context.collection.objects.link(cam); scn.camera = cam
key = bpy.data.objects.new("Key", bpy.data.lights.new("Key", 'SUN')); key.data.energy = 3.0
bpy.context.collection.objects.link(key)
fill = bpy.data.objects.new("Fill", bpy.data.lights.new("Fill", 'SUN')); fill.data.energy = 1.2
bpy.context.collection.objects.link(fill)
z0 = W[:, 2].min()
for name, f_lo, f_hi, yawdeg in [("chest_front", 0.60, 0.82, 0), ("chest_34", 0.60, 0.82, 40),
("hip_front", 0.38, 0.60, 0), ("hip_34", 0.38, 0.60, 40),
("hip_back", 0.38, 0.60, 180), ("full_front", 0.0, 1.0, 0)]:
z_lo, z_hi = z0 + f_lo * H, z0 + f_hi * H
band = W[(W[:, 2] >= z_lo) & (W[:, 2] <= z_hi)]
band = band if len(band) else W
ctr = Vector((0.0, float(band[:, 1].mean()), (z_lo + z_hi) / 2))
span = max(float(band[:, 0].max() - band[:, 0].min()), z_hi - z_lo)
if f_hi - f_lo < 0.5:
span = min(span, (z_hi - z_lo) * 1.25)
yaw = math.radians(yawdeg); dist = span * 2.9
cam.location = ctr + Vector((math.sin(yaw) * dist, -math.cos(yaw) * dist, 0.0))
cam.rotation_euler = (ctr - cam.location).to_track_quat('-Z', 'Y').to_euler()
key.rotation_euler = (math.radians(60), 0, math.radians(35 + yawdeg))
fill.rotation_euler = (math.radians(75), 0, math.radians(yawdeg - 110))
scn.render.filepath = os.path.join(OUT, f"mask_{name}.png")
bpy.ops.render.render(write_still=True)
print("RENDER_OK", scn.render.filepath, flush=True)