Files
animation/characters/work/lena/48_decimate_headsafe.py
T
jeremy c12c4f156c feat(lena): v02 lane recipes + the archival full-res master
The v02 chain, which exists because bisecting the shard-eye defect proved it lives in
the hires MASTER rather than in any v02 step: the early heal chain ran whole-mesh welds
and reset custom split normals, and the eye/lash shells only read as an eye through
Tripo's authored normals. Every descendant inherits it, including the shipped body.

  48_decimate_headsafe  body/hands only, head bit-identical (feathered ramp)
  49_seams_v02          24_seams with a density-aware hip landmark — the v01 rule fired
                        at u=0.610, mid-belly, on the head-protected vert count
  50_seams_headuv       body-only unwrap; the head KEEPS its original Tripo charts.
                        SLIM collapsed the undecimated lash/brow slivers to points and
                        ANGLE_BASED packed at half v01's texel density; the face was
                        the best-mapped region of the source atlas, so it is reused
  51_reatlas_v02        31_reatlas + centroid splats for sub-texel triangles — the
                        skipped set IS the lashes, which rendered as grey glass
  52_head_transplant    the PRISTINE ORIGINAL head onto the decimated nude body
  53_rig_transfer       54_crotch_refill

55_fullres_v02.blend is pinned in .lanekeep as THE archival master: full-res nude body
+ pristine original head, crotch refill and texture despeckle applied, 883,404 v /
1,761,640 f. It supersedes 34_v04 as the lane root (34_v04's head has the shard eyes).

Also: tools/graft_hands.py, the Marvelous Designer hunter-skirt configs v1-v8, the
hunter cloth texture generator, and Mako's measurement card.

Per .agents/rules/working-files.md the per-attempt .blend files under work/lena/v02
are SCRATCH ("never committed") — the .py recipes here are the history and regenerate
any of them from the pinned master. See the ignore rule landing next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:53:17 -07:00

161 lines
7.1 KiB
Python

# Stage 48 (v02 candidate): decimate the body, KEEP THE HEAD — Jeremy 2026-08-11.
#
# blender --background --python 48_decimate_headsafe.py -- <in.blend> <out.blend> [body_target_v]
#
# The v01 decimation was one global ratio (30_decimate.py, 0.037728): it kept 4% of her head
# triangles, and the face is where that shows — Tripo modelled her brows, lash lines and lips as
# raised GEOMETRY, so the 197,306-tri head collapsed to 7,836 and the brows went faceted.
#
# Here the head is excluded outright and the reduction is spent on the body alone:
# - vertex group 'decim': weight 1.0 below u=0.80 (shoulders), feathered to 0.0 at u=0.86
# (chin). The feather ramps triangle density through the neck instead of snapping at a line.
# - Decimate COLLAPSE with that group; the ratio is BISECTED on the evaluated depsgraph until
# the BODY-region vertex count lands on target (the same measure-don't-guess pattern as
# 30_decimate — the ratio is faces-global, so the body count is what must converge).
# - The head is then ASSERTED untouched: exact vertex count and positional checksum over
# u > 0.87, not hoped for. If the modifier nibbled it, this aborts rather than ships.
#
# Budget consequence, stated up front: head 101,136 v rides along whole, so the result is
# ~128k v / ~250k tris — 4x the v01 game budget. That is the point: quality over budget,
# Jeremy's call. The head budget can be dialled later; the body work is not redone.
import bpy, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
BODY_TARGET = int(argv[2]) if len(argv) > 2 else 27500 # v01's body-region density
# Feather narrowed 0.80-0.86 -> 0.84-0.87 after the first run: the wide band held 26,405
# partially-protected verts, and because the bisect counted everything below u=0.87 as "body",
# the band soaked up nearly the whole 27,500 budget — the true body came out at ~4k verts.
# The bisect now counts ONLY u <= FEATHER_LO, so the budget lands where it was meant to.
FEATHER_LO, FEATHER_HI = 0.84, 0.87
HEAD_CHECK = 0.875
t0 = time.time()
def log(m):
print(f"[dec48 {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))
bpy.context.view_layer.objects.active = ob
for o in bpy.data.objects:
o.select_set(o is ob)
me = ob.data
n0 = len(me.vertices)
co = np.empty(n0 * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
zlo, zhi = co[:, 2].min(), co[:, 2].max()
u = (co[:, 2] - zlo) / (zhi - zlo)
head0 = u > HEAD_CHECK
head_n0 = int(head0.sum())
head_sum0 = co[head0].sum(axis=0) # positional checksum of the protected region
log(f"in: {n0} v / {len(me.polygons)} f head(u>{HEAD_CHECK}): {head_n0} v")
# feathered protection weights
w = np.clip((FEATHER_HI - u) / (FEATHER_HI - FEATHER_LO), 0.0, 1.0)
vg = ob.vertex_groups.get("decim") or ob.vertex_groups.new(name="decim")
for band in (0.0, 0.25, 0.5, 0.75, 1.0):
idx = np.nonzero(np.isclose(np.round(w * 4) / 4, band))[0]
if len(idx):
vg.add(idx.tolist(), float(band), 'REPLACE')
log(f"vertex group: {int((w >= 0.999).sum())} full-weight, "
f"{int(((w > 0) & (w < 1)).sum())} feathered, {int((w <= 0).sum())} protected")
# Applying Decimate discards CUSTOM SPLIT NORMALS for the entire mesh — measured consequence:
# the head's lash and eye shells (dense sliver geometry that depends on Tripo's authored normals)
# rendered as faceted glass. Keep an untouched duplicate as a normal donor; after the decimate is
# applied, the head's normals are transferred back. Positions up there are identical by
# construction, so POLYINTERP_NEAREST is an exact restore, feathered off through the neck.
donor = ob.copy()
donor.data = ob.data.copy()
donor.name = "nrm_donor"
bpy.context.scene.collection.objects.link(donor)
mod = ob.modifiers.new("dec", 'DECIMATE')
mod.decimate_type = 'COLLAPSE'
mod.vertex_group = "decim"
mod.vertex_group_factor = 10.0
def body_count(ratio):
mod.ratio = ratio
dg = bpy.context.evaluated_depsgraph_get()
ev = ob.evaluated_get(dg)
m = ev.to_mesh()
n = len(m.vertices)
a = np.empty(n * 3); m.vertices.foreach_get("co", a); a = a.reshape(-1, 3)
uu = (a[:, 2] - zlo) / (zhi - zlo)
body = int((uu <= FEATHER_LO).sum()) # TRUE body only — the feather band is excluded
head = int((uu > HEAD_CHECK).sum())
ev.to_mesh_clear()
return body, head, n
# analytic first guess: protected faces survive, so the global ratio must budget for them
face_head = 0
lv = np.empty(len(me.loops), dtype=np.int32); me.loops.foreach_get("vertex_index", lv)
ls = np.empty(len(me.polygons), dtype=np.int32); me.polygons.foreach_get("loop_start", ls)
prot = u[lv[ls]] > FEATHER_HI # cheap: classify face by first corner
face_head = int(prot.sum())
guess = (face_head + BODY_TARGET * 2.05) / len(me.polygons)
lo_r, hi_r = guess * 0.4, min(1.0, guess * 2.5)
log(f"protected faces ~{face_head}; first guess ratio {guess:.5f}")
best = None
for it in range(9):
r = 0.5 * (lo_r + hi_r)
b, h, n = body_count(r)
log(f" it{it:02d} ratio {r:.5f} -> body {b} v, head {h} v, total {n}")
if abs(b - BODY_TARGET) / BODY_TARGET < 0.02:
best = r
break
if b < BODY_TARGET:
lo_r = r
else:
hi_r = r
best = r
mod.ratio = best
bpy.ops.object.modifier_apply(modifier=mod.name)
log(f"applied ratio {best:.5f}")
# ---- assert the head did not move ----
me = ob.data
n1 = len(me.vertices)
co1 = np.empty(n1 * 3); me.vertices.foreach_get("co", co1); co1 = co1.reshape(-1, 3)
u1 = (co1[:, 2] - zlo) / (zhi - zlo)
head1 = u1 > HEAD_CHECK
head_n1 = int(head1.sum())
head_sum1 = co1[head1].sum(axis=0)
drift = np.abs(head_sum1 - head_sum0).max()
print(f"\nHEAD CHECK: {head_n0} -> {head_n1} verts, positional checksum drift {drift:.9f}")
assert head_n1 == head_n0 and drift < 1e-4, \
"the decimation touched the protected head — do not ship this"
print(f"RESULT: {n0} -> {n1} v ({len(me.polygons)} f); body {n1 - head_n1} v, head {head_n1} v")
print(f"height unchanged: {co1[:,2].max()-co1[:,2].min():.5f} vs {zhi-zlo:.5f}")
# restore the head's authored normals from the donor
vgn = ob.vertex_groups.new(name="nrm_keep")
co2 = np.empty(len(me.vertices) * 3); me.vertices.foreach_get("co", co2); co2 = co2.reshape(-1, 3)
u2 = (co2[:, 2] - zlo) / (zhi - zlo)
wn = np.clip((u2 - 0.82) / (0.86 - 0.82), 0.0, 1.0)
for band in (0.25, 0.5, 0.75, 1.0):
idx = np.nonzero(np.isclose(np.round(wn * 4) / 4, band))[0]
if len(idx):
vgn.add(idx.tolist(), float(band), 'REPLACE')
dt = ob.modifiers.new("nrm", 'DATA_TRANSFER')
dt.object = donor
dt.use_loop_data = True
dt.data_types_loops = {'CUSTOM_NORMAL'}
dt.loop_mapping = 'POLYINTERP_NEAREST'
dt.vertex_group = "nrm_keep"
bpy.context.view_layer.objects.active = ob
bpy.ops.object.modifier_apply(modifier=dt.name)
log("head custom normals restored from donor (feathered 0.82-0.86)")
bpy.data.objects.remove(donor, do_unlink=True)
ob.vertex_groups.remove(ob.vertex_groups["nrm_keep"])
ob.vertex_groups.remove(ob.vertex_groups["decim"])
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("DEC48_DONE")