161 lines
7.1 KiB
Python
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")
|