eae8327811
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>
284 lines
14 KiB
Python
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)
|