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>
This commit is contained in:
2026-08-12 18:53:17 -07:00
parent 23fc4b378e
commit c12c4f156c
17 changed files with 3828 additions and 0 deletions
@@ -0,0 +1,160 @@
# 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")
+442
View File
@@ -0,0 +1,442 @@
# Stage 49 (v02): 24_seams.py with ONE fix — a density-aware hip landmark.
#
# The v01 hip rule walked 0.01-thick midline slices and declared the crotch at the first slice
# holding <3 verts. On the head-protected v02 body (27.5k body verts vs 32k) that fired at
# u=0.610 — mid-belly. A hip ring above the real crotch turns the leg region into two joined
# tubes with too few cuts, and the minimum-stretch solver blows one chart up and packs the rest
# to nothing (coverage 0.0%). Slices are 2x thicker here and the threshold scales with the
# body's actual vertex density. Copied rather than edited: 24_seams.py is the recipe that
# regenerates the SHIPPED v01, and its behaviour must not drift.
# Stage 24: a proper human UV atlas — ~10 anatomical charts instead of Tripo's 5,870 blobs.
#
# blender --background --python 24_seams.py -- <mesh.glb|blend> <out_dir> [decimate_ratio]
#
# Every seam is placed off a MEASURED landmark, not a guessed threshold, so the same rules port
# between the hires sculpt and the decimated game body:
# neck / wrist / ankle = local minimum of cross-section radius (the narrow part)
# shoulder = smallest |s| whose slice is short in u (arm, not torso)
# hip = lowest slice that still has vertices on the midline (the crotch)
# Lengthwise cuts open each tube flat, hidden where nobody looks:
# torso + head -> back midline; arms -> back of the arm; legs -> inner side.
# Hands / feet / head need no lengthwise cut: a tube with one closed end is already a disc.
import bpy, bmesh, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
SRC = argv[0]
OUTDIR = os.path.abspath(argv[1])
RATIO = float(argv[2]) if len(argv) > 2 else 0.0
os.makedirs(OUTDIR, exist_ok=True)
t0 = time.time()
def log(m):
print(f"[seam {time.time()-t0:6.1f}s] {m}", flush=True)
if SRC.lower().endswith(".glb"):
bpy.ops.wm.read_homefile(use_empty=True)
bpy.ops.import_scene.gltf(filepath=SRC)
else:
bpy.ops.wm.open_mainfile(filepath=SRC)
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)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
log(f"body '{ob.name}' {len(ob.data.vertices)}v {len(ob.data.polygons)}f")
if 0 < RATIO < 1:
m = ob.modifiers.new("dec", 'DECIMATE')
m.ratio = RATIO
bpy.ops.object.modifier_apply(modifier=m.name)
log(f"decimated -> {len(ob.data.vertices)}v {len(ob.data.polygons)}f")
# The source mesh is non-manifold (1,649 edges with >2 faces) and decimation turns that into a
# scatter of degenerate/duplicate triangles. Each one becomes its own UV island — that is where
# the confetti comes from, not the seam rules. Clean it here, before any of it reaches the unwrap.
def clean_mesh(tag):
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.remove_doubles(threshold=1e-5)
bpy.ops.mesh.dissolve_degenerate(threshold=1e-6)
bpy.ops.mesh.delete_loose(use_verts=True, use_edges=True, use_faces=False)
# (Splitting the 1,649 non-manifold edges was tried here and rejected: it cost +3,512 verts
# and made the collapsed-face count worse, because the collapse is a solver problem, not a
# topology one — see the unwrap method below.)
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
bm_ = bmesh.new(); bm_.from_mesh(ob.data)
nm = sum(1 for e in bm_.edges if len(e.link_faces) > 2)
dg = sum(1 for f in bm_.faces if f.calc_area() < 1e-12)
bm_.free()
log(f"clean[{tag}]: {len(ob.data.vertices)}v {len(ob.data.polygons)}f "
f"nonmanifold_edges={nm} degenerate_faces={dg}")
clean_mesh("after decimate")
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
lo, hi = co.min(axis=0), co.max(axis=0)
span = hi - lo
UP = int(np.argmax(span))
LR = int(np.argmax(np.where(np.arange(3) == UP, -1, span)))
FB = 3 - UP - LR
u = (co[:, UP] - lo[UP]) / span[UP]
s = co[:, LR] - 0.5 * (lo[LR] + hi[LR])
d = co[:, FB] - 0.5 * (lo[FB] + hi[FB])
HALF = 0.5 * span[LR]
sn = s / HALF # left-right, normalised to +-1
print(f"axes up={'xyz'[UP]} lr={'xyz'[LR]} fb={'xyz'[FB]} height {span[UP]:.4f} units")
# ---------------------------------------------------------------- landmarks
def radius_profile(mask, coord, lo_c, hi_c, nb):
"""mean in-slice radius vs coord, over `nb` bins — the narrow parts are the joints."""
ed = np.linspace(lo_c, hi_c, nb + 1)
mid = 0.5 * (ed[:-1] + ed[1:])
out = np.full(nb, np.nan)
for i in range(nb):
m = mask & (coord >= ed[i]) & (coord < ed[i + 1])
if m.sum() < 30:
continue
A = np.stack([s[m], d[m], u[m]], axis=1)
A = np.delete(A, 0 if coord is sn else 2, axis=1) if False else A
# radius measured in the two axes perpendicular to `coord`
if coord is u:
P = np.stack([s[m], d[m]], axis=1)
else:
P = np.stack([d[m], (u[m] - u[m].mean()) * span[UP]], axis=1)
out[i] = np.linalg.norm(P - P.mean(axis=0), axis=1).mean()
return mid, out
def local_min(mid, prof, lo_c, hi_c):
m = (mid >= lo_c) & (mid <= hi_c) & np.isfinite(prof)
if not m.any():
return 0.5 * (lo_c + hi_c)
return float(mid[m][np.argmin(prof[m])])
def taper_end(mid, prof, lo_c, hi_c, from_high, tol=0.08):
"""Where the limb stops tapering, taken from the EXTREMITY side.
The global minimum of the radius profile is not the joint: on the leg it sits up the shin,
well above where the foot ends. The joint is the end of the taper nearest the extremity —
the first bin (walking in from that side) that reaches within `tol` of the minimum.
"""
m = (mid >= lo_c) & (mid <= hi_c) & np.isfinite(prof)
if not m.any():
return 0.5 * (lo_c + hi_c)
mm, pp = mid[m], prof[m]
close = np.nonzero(pp <= pp.min() * (1.0 + tol))[0]
return float(mm[close[-1] if from_high else close[0]])
torso_side = np.abs(sn) < 0.30
mid_u, prof_u = radius_profile(torso_side, u, 0.0, 1.0, 60)
NECK_U = local_min(mid_u, prof_u, 0.80, 0.93)
# The ankle must be measured on ONE leg. Over both, the "radius" is really the gap between
# them, which falls monotonically from the feet up and has no minimum to find.
one_leg = (sn > 0.05) & (u < 0.35)
mid_l, prof_l = radius_profile(one_leg, u, 0.0, 0.35, 35)
print(" single-leg radius profile (u -> radius), the ankle is the narrow point:")
print(" " + " ".join(f"{m:.2f}:{r*1000:.0f}" for m, r in zip(mid_l, prof_l)
if np.isfinite(r)))
ANKLE_U = taper_end(mid_l, prof_l, 0.02, 0.14, from_high=False)
# shoulder: the smallest |s| whose slice is SHORT in u (an arm), scanning outward
absn = np.abs(sn)
ARM_IN = 0.35
for cut in np.arange(0.15, 0.60, 0.01):
m = (absn >= cut) & (absn < cut + 0.03)
if m.sum() < 30:
continue
if (u[m].max() - u[m].min()) < 0.13:
ARM_IN = float(cut)
break
mid_a, prof_a = radius_profile(absn > ARM_IN, absn, ARM_IN, 1.0, 40)
print(" arm radius profile (|s| -> radius), the wrist is the narrow point before the hand:")
print(" " + " ".join(f"{m:.2f}:{r*1000:.0f}" for m, r in zip(mid_a, prof_a)
if np.isfinite(r)))
# The profile reads: radius falls to the wrist, bulges again over the palm, then tapers down
# the fingers. Search below the palm bulge or the "wrist" lands among the fingers.
WRIST = taper_end(mid_a, prof_a, 0.60, 0.80, from_high=True)
# hip: the crotch is the HIGHEST strictly-empty midline slice. Measured on this mesh: the
# |sn|<0.025 strip is zero below u=0.42 and populated above — except one freak slice at
# 0.50-0.52 holding a single vertex, which is what tripped both threshold-based rules (v01's
# "<3 verts" and the density-scaled version) into calling mid-belly the crotch. No stray
# vertex can fake a STRICTLY empty slice, and there is no surface between the legs to put
# one there.
HIP_U = 0.45
for lv in np.arange(0.60, 0.20, -0.005):
m = (u >= lv) & (u < lv + 0.02) & (absn < 0.025)
if m.sum() == 0:
HIP_U = float(lv + 0.02)
break
print(f"LANDMARKS neck_u {NECK_U:.3f} hip_u {HIP_U:.3f} ankle_u {ANKLE_U:.3f} "
f"arm_in {ARM_IN:.3f} wrist {WRIST:.3f} (fractions of height / half-span)")
is_arm = absn > ARM_IN
is_hand = absn > WRIST
is_head = u > NECK_U
is_leg = (u < HIP_U) & ~is_arm
is_foot = u < ANKLE_U
# centre lines for the lengthwise cuts
# A constant centre works only for a perfectly axis-aligned limb. Hers droop, so a level set of
# u wanders off the arm and cuts it twice. Use a measured centre LINE: median u per |s| bin for
# the arms, median |s| per u bin for each leg, linearly interpolated.
def centre_line(mask, along, of, nb, lo_a, hi_a):
ed = np.linspace(lo_a, hi_a, nb + 1)
mid = 0.5 * (ed[:-1] + ed[1:])
val = np.full(nb, np.nan)
for i in range(nb):
m = mask & (along >= ed[i]) & (along < ed[i + 1])
if m.sum() >= 20:
val[i] = np.median(of[m])
ok = np.isfinite(val)
if ok.sum() < 2:
return lambda q: np.full_like(q, np.nanmedian(of[mask]) if mask.any() else 0.0)
mid_o, val_o = mid[ok], val[ok]
return lambda q: np.interp(np.asarray(q), mid_o, val_o)
arm_u_of = centre_line(is_arm & ~is_hand, absn, u, 24, ARM_IN, WRIST)
leg_s_of = {}
for sg in (-1, 1):
m = is_leg & ~is_foot & (np.sign(sn) == sg)
leg_s_of[sg] = centre_line(m, u, absn, 20, ANKLE_U, HIP_U)
# A foot cut at the ankle is an L (ankle-heel-toes) and a hand is a flat paddle; neither
# flattens as one chart. Split each along its silhouette, exactly where an artist would:
# the foot into upper/sole at its mid-height, the hand into back/palm at its mid-thickness.
U_SOLE = float(np.median(u[is_foot])) if is_foot.sum() else ANKLE_U * 0.5
D_PALM = float(np.median(d[is_hand])) if is_hand.sum() else 0.0
print(f" foot split at u {U_SOLE:.3f} (upper|sole) hand split at d {D_PALM:+.4f} (back|palm)")
qa = np.linspace(ARM_IN, WRIST, 5)
print(f" arm centre line u at |s|={np.round(qa,2).tolist()}: "
f"{np.round(arm_u_of(qa), 3).tolist()}")
ql = np.linspace(ANKLE_U, HIP_U, 5)
print(f" leg(+) centre line |s| at u={np.round(ql,2).tolist()}: "
f"{np.round(leg_s_of[1](ql), 3).tolist()}")
# The torso is a tube with FOUR holes (neck, two armholes, hip). The back midline joins neck to
# hip; two more cuts are needed or the chart stays multiply-connected and the unwrap stretches it
# badly. Run a relief cut across the BACK at armpit height, from each armhole in to the midline.
# upper body only — at |s| = ARM_IN the feet also splay out that far, and they win a p5
m_pit = (np.abs(absn - ARM_IN) < 0.03) & (u > 0.5)
U_PIT = float(np.percentile(u[m_pit], 5)) if m_pit.sum() > 30 else 0.62
print(f" armpit u {U_PIT:.3f} -> relief cut across the back at that height")
# ---------------------------------------------------------------- mark seams
bm = bmesh.new()
bm.from_mesh(me)
tally = {}
def hit(k):
tally[k] = tally.get(k, 0) + 1
return True
for e in bm.edges:
e.seam = False
for e in bm.edges:
a, b = e.verts[0].index, e.verts[1].index
# rings, in order of priority
if is_head[a] != is_head[b]:
e.seam = hit("neck ring"); continue
if is_hand[a] != is_hand[b]:
e.seam = hit("wrist rings"); continue
if is_foot[a] != is_foot[b]:
e.seam = hit("ankle rings"); continue
if is_arm[a] != is_arm[b]:
e.seam = hit("shoulder rings"); continue
if (u[a] < HIP_U) != (u[b] < HIP_U):
e.seam = hit("hip ring"); continue
# lengthwise cuts
if is_foot[a] and is_foot[b]:
if (u[a] > U_SOLE) != (u[b] > U_SOLE):
e.seam = hit("foot sole line"); continue
elif is_hand[a] and is_hand[b]:
if (d[a] > D_PALM) != (d[b] > D_PALM):
e.seam = hit("hand palm line"); continue
elif is_arm[a] and is_arm[b] and not (is_hand[a] or is_hand[b]):
ca, cb = arm_u_of(absn[a]), arm_u_of(absn[b])
if d[a] > 0 and d[b] > 0 and (u[a] > ca) != (u[b] > cb):
e.seam = hit("arm back line"); continue
elif is_leg[a] and is_leg[b] and not (is_foot[a] or is_foot[b]):
sg = int(1 if sn[a] + sn[b] >= 0 else -1)
if absn[a] < leg_s_of[sg](u[a]) and absn[b] < leg_s_of[sg](u[b]) \
and (d[a] > 0) != (d[b] > 0):
e.seam = hit("leg inner line"); continue
elif not (is_arm[a] or is_arm[b] or is_foot[a] or is_foot[b]):
# torso and head share one continuous back midline
if d[a] > 0 and d[b] > 0 and (sn[a] > 0) != (sn[b] > 0):
e.seam = hit("back midline"); continue
# armpit relief: back only, from each armhole inward to the midline
if d[a] > 0 and d[b] > 0 and not is_head[a] and not is_head[b] \
and (u[a] > U_PIT) != (u[b] > U_PIT):
e.seam = hit("armpit relief"); continue
for k in sorted(tally):
print(f" seam '{k}': {tally[k]} edges")
bm.to_mesh(me)
bm.free()
log(f"marked {sum(tally.values())} seam edges")
# ---------------------------------------------------------------- unwrap
UNWRAP_METHOD = os.environ.get("UNWRAP_METHOD", "MINIMUM_STRETCH")
def do_unwrap(pack):
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
# Angle-based (ABF) is conformal: it preserves angles and is free to crush area. At her folds
# and flaps that means whole patches land under one texel and come out untextured. The
# minimum-stretch (SLIM) solver optimises area distortion instead, which is what a texture
# transfer actually needs.
try:
bpy.ops.uv.unwrap(method=UNWRAP_METHOD, margin=0.0)
except TypeError:
log(f"unwrap method {UNWRAP_METHOD} unavailable — falling back to ANGLE_BASED")
bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=0.0)
bpy.ops.uv.average_islands_scale()
if pack:
try:
bpy.ops.uv.pack_islands(rotate=True, margin=0.003, scale=True)
except TypeError:
bpy.ops.uv.pack_islands(margin=0.003)
bpy.ops.object.mode_set(mode='OBJECT')
def get_islands():
m_ = ob.data
n_l_, n_f_ = len(m_.loops), len(m_.polygons)
lv = np.empty(n_l_, dtype=np.int32); m_.loops.foreach_get("vertex_index", lv)
uv_ = np.empty(n_l_ * 2); m_.uv_layers.active.data.foreach_get("uv", uv_)
uv_ = uv_.reshape(-1, 2)
ls = np.empty(n_f_, dtype=np.int32); m_.polygons.foreach_get("loop_start", ls)
lt = np.empty(n_f_, dtype=np.int32); m_.polygons.foreach_get("loop_total", lt)
li_ = ls[lt == 3]
Q = 1 << 20
k = (lv.astype(np.int64) * Q * Q
+ np.round(np.clip(uv_[:, 0], 0, 1) * (Q - 1)).astype(np.int64) * Q
+ np.round(np.clip(uv_[:, 1], 0, 1) * (Q - 1)).astype(np.int64))
_, uvv_ = np.unique(k, return_inverse=True)
n_uvv_ = uvv_.max() + 1
T_ = np.stack([uvv_[li_], uvv_[li_ + 1], uvv_[li_ + 2]], axis=1)
par = np.arange(n_uvv_, dtype=np.int64)
def find(x):
r = x
while par[r] != r:
r = par[r]
while par[x] != r:
par[x], x = r, par[x]
return r
for a_, b_, c_ in T_:
ra, rb, rc = find(a_), find(b_), find(c_)
if ra != rb:
par[rb] = ra
if ra != rc:
par[rc] = ra
_, isl_ = np.unique(np.array([find(i) for i in range(n_uvv_)]), return_inverse=True)
return isl_, isl_[T_[:, 0]], li_, lv, uv_, isl_.max() + 1
# Keep the source UVs on a second layer. Stage 31 needs them to resample the existing maps into
# the new layout — unwrapping over them in place would throw the textures away.
src_layer = ob.data.uv_layers.active
src_layer.name = "UVMap_tripo"
new_layer = ob.data.uv_layers.new(name="UVMap_atlas", do_init=True)
ob.data.uv_layers.active = new_layer
for i, l in enumerate(ob.data.uv_layers):
if l is new_layer:
ob.data.uv_layers.active_index = i
log(f"uv layers: {[l.name for l in ob.data.uv_layers]} active="
f"{ob.data.uv_layers.active.name}")
do_unwrap(pack=True)
isl, fisl, li, loops_v, uv, n_isl = get_islands()
log("unwrapped + packed")
tuv = np.stack([np.clip(uv, 0, 1)[li], np.clip(uv, 0, 1)[li + 1], np.clip(uv, 0, 1)[li + 2]], 1)
auv = 0.5 * np.abs((tuv[:, 1, 0] - tuv[:, 0, 0]) * (tuv[:, 2, 1] - tuv[:, 0, 1])
- (tuv[:, 2, 0] - tuv[:, 0, 0]) * (tuv[:, 1, 1] - tuv[:, 0, 1]))
me = ob.data
co = np.empty(len(me.vertices) * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
P3 = co[np.stack([loops_v[li], loops_v[li + 1], loops_v[li + 2]], axis=1)]
a3 = 0.5 * np.linalg.norm(np.cross(P3[:, 1] - P3[:, 0], P3[:, 2] - P3[:, 0]), axis=1)
isl_auv = np.bincount(fisl, weights=auv, minlength=n_isl)
isl_a3 = np.bincount(fisl, weights=a3, minlength=n_isl)
isl_nf = np.bincount(fisl, minlength=n_isl)
UNITM = 1.777 / span[UP]
W = 4096
dens = np.where(isl_a3 > 0, np.sqrt(np.maximum(isl_auv, 0) / np.maximum(isl_a3, 1e-12))
* W / (UNITM * 1000.0), np.nan)
o = np.argsort(-isl_auv)
cum = np.cumsum(isl_auv[o]) / max(isl_auv.sum(), 1e-12)
n99 = int(np.searchsorted(cum, 0.99)) + 1
print(f"\n=== NEW ATLAS (Tripo baseline in brackets) ===")
print(f"islands {n_isl} [5870] 99%-of-area islands {n99} [84]")
print(f"coverage {isl_auv.sum()*100:.1f}% [62.0] confetti <20 faces {int((isl_nf<20).sum())} [5763]")
db = dens[o[:n99]]; db = db[np.isfinite(db)]
print(f"texel density {db.min():.2f}..{db.max():.2f} px/mm -> {db.max()/max(db.min(),1e-9):.2f}x "
f"spread [1.8x]")
# distortion, measured only over the real charts — the confetti islands are degenerate
# triangles whose ratios are meaningless and would own the tail
real = np.isin(fisl, o[:n99])
ok = real & (a3 > 1e-12) & (auv > 1e-14)
sc = np.sqrt(auv[ok] / a3[ok]); sc /= np.median(sc)
print(f"per-triangle area-scale vs median, real charts only: p05 {np.percentile(sc,5):.2f} "
f"p95 {np.percentile(sc,95):.2f} p99 {np.percentile(sc,99):.2f} (1.0 = undistorted)")
print("\nthe charts: # faces uv_area% 3D cm2 stretch_p95 where")
for i in o[:16]:
if isl_auv[i] * 100 < 0.05:
break
m = fisl == i
mo = m & (a3 > 1e-12) & (auv > 1e-14)
st = np.sqrt(auv[mo] / a3[mo])
st = st / np.median(st) if len(st) else np.array([1.0])
vs = np.unique(np.stack([loops_v[li], loops_v[li + 1], loops_v[li + 2]], 1)[m].ravel())
lab = []
for nm, msk in (("head", is_head), ("hand", is_hand), ("foot", is_foot),
("arm", is_arm & ~is_hand), ("leg", is_leg & ~is_foot)):
if msk[vs].mean() > 0.6:
lab.append(nm)
side = "L" if sn[vs].mean() > 0.15 else ("R" if sn[vs].mean() < -0.15 else "mid")
print(f" {i:6d} {isl_nf[i]:8d} {isl_auv[i]*100:9.3f} {isl_a3[i]*UNITM*UNITM*1e4:9.1f} "
f"{np.percentile(st,95):11.2f} {'+'.join(lab) or 'torso'} {side}")
# picture
R = 1024
rng = np.random.RandomState(3)
pal = rng.rand(n_isl, 3) * 0.7 + 0.25
IS = np.zeros((R, R, 3))
tp = tuv * (R - 1)
for fi in range(len(tp)):
P = tp[fi]
x0, x1 = int(P[:, 0].min()), int(np.ceil(P[:, 0].max()))
y0, y1 = int(P[:, 1].min()), int(np.ceil(P[:, 1].max()))
if x1 < x0 or y1 < y0 or x1 - x0 > 64 or y1 - y0 > 64:
continue
dt = ((P[1, 1] - P[2, 1]) * (P[0, 0] - P[2, 0]) + (P[2, 0] - P[1, 0]) * (P[0, 1] - P[2, 1]))
if abs(dt) < 1e-12:
continue
gx, gy = np.meshgrid(np.arange(x0, min(x1, R - 1) + 1), np.arange(y0, min(y1, R - 1) + 1))
aa = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / dt
bb = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / dt
ins = (aa >= 0) & (bb >= 0) & (1 - aa - bb >= 0)
if ins.any():
IS[gy[ins], gx[ins]] = pal[fisl[fi]]
img = bpy.data.images.new("isl", R, R, alpha=False)
A = np.ones((R, R, 4), dtype=np.float32); A[:, :, :3] = IS
img.pixels.foreach_set(A.reshape(-1))
p = os.path.join(OUTDIR, "charts.png")
img.file_format = 'PNG'; img.filepath_raw = p; img.save(filepath=p)
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUTDIR, "seamed.blend"))
log(f"wrote {p} + seamed.blend")
print("SEAMS_DONE")
+531
View File
@@ -0,0 +1,531 @@
# Stage 50 (v02): body-only unwrap; the head KEEPS its original Tripo UVs.
#
# Chain of failures that led here, all measured on v02/48_headsafe.blend:
# - MINIMUM_STRETCH over everything: the undecimated head carries Tripo's eyelash and brow
# shells — thousands of needle slivers — and SLIM COLLAPSED those charts to points
# (half of all loops at one UV coordinate, coverage 0.0%). v01 never saw this because
# decimation had already crushed the lashes before any unwrap ran.
# - ANGLE_BASED over everything: survived the slivers but packed at 17.3% coverage,
# 1.15 px/mm — half of v01's texel density, on the version whose whole point is quality.
#
# The escape: the head was deliberately never edited, so its ORIGINAL Tripo UV charts are
# still valid — and they are artist-grade (the face was the best-mapped region of the source
# atlas, ~2.6 px/mm). So: SLIM on the body only (proven at this density in v01),
# average-island-scale to equalise everything, boost the head charts by HEAD_BOOST for the
# quality this version exists for, then pack the lot together.
# Stage 49 (v02): 24_seams.py with ONE fix — a density-aware hip landmark.
#
# The v01 hip rule walked 0.01-thick midline slices and declared the crotch at the first slice
# holding <3 verts. On the head-protected v02 body (27.5k body verts vs 32k) that fired at
# u=0.610 — mid-belly. A hip ring above the real crotch turns the leg region into two joined
# tubes with too few cuts, and the minimum-stretch solver blows one chart up and packs the rest
# to nothing (coverage 0.0%). Slices are 2x thicker here and the threshold scales with the
# body's actual vertex density. Copied rather than edited: 24_seams.py is the recipe that
# regenerates the SHIPPED v01, and its behaviour must not drift.
# Stage 24: a proper human UV atlas — ~10 anatomical charts instead of Tripo's 5,870 blobs.
#
# blender --background --python 24_seams.py -- <mesh.glb|blend> <out_dir> [decimate_ratio]
#
# Every seam is placed off a MEASURED landmark, not a guessed threshold, so the same rules port
# between the hires sculpt and the decimated game body:
# neck / wrist / ankle = local minimum of cross-section radius (the narrow part)
# shoulder = smallest |s| whose slice is short in u (arm, not torso)
# hip = lowest slice that still has vertices on the midline (the crotch)
# Lengthwise cuts open each tube flat, hidden where nobody looks:
# torso + head -> back midline; arms -> back of the arm; legs -> inner side.
# Hands / feet / head need no lengthwise cut: a tube with one closed end is already a disc.
import bpy, bmesh, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
SRC = argv[0]
OUTDIR = os.path.abspath(argv[1])
RATIO = float(argv[2]) if len(argv) > 2 else 0.0
os.makedirs(OUTDIR, exist_ok=True)
t0 = time.time()
def log(m):
print(f"[seam {time.time()-t0:6.1f}s] {m}", flush=True)
if SRC.lower().endswith(".glb"):
bpy.ops.wm.read_homefile(use_empty=True)
bpy.ops.import_scene.gltf(filepath=SRC)
else:
bpy.ops.wm.open_mainfile(filepath=SRC)
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)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
log(f"body '{ob.name}' {len(ob.data.vertices)}v {len(ob.data.polygons)}f")
if 0 < RATIO < 1:
m = ob.modifiers.new("dec", 'DECIMATE')
m.ratio = RATIO
bpy.ops.object.modifier_apply(modifier=m.name)
log(f"decimated -> {len(ob.data.vertices)}v {len(ob.data.polygons)}f")
# The source mesh is non-manifold (1,649 edges with >2 faces) and decimation turns that into a
# scatter of degenerate/duplicate triangles. Each one becomes its own UV island — that is where
# the confetti comes from, not the seam rules. Clean it here, before any of it reaches the unwrap.
def clean_mesh(tag):
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.remove_doubles(threshold=1e-5)
bpy.ops.mesh.dissolve_degenerate(threshold=1e-6)
bpy.ops.mesh.delete_loose(use_verts=True, use_edges=True, use_faces=False)
# (Splitting the 1,649 non-manifold edges was tried here and rejected: it cost +3,512 verts
# and made the collapsed-face count worse, because the collapse is a solver problem, not a
# topology one — see the unwrap method below.)
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
bm_ = bmesh.new(); bm_.from_mesh(ob.data)
nm = sum(1 for e in bm_.edges if len(e.link_faces) > 2)
dg = sum(1 for f in bm_.faces if f.calc_area() < 1e-12)
bm_.free()
log(f"clean[{tag}]: {len(ob.data.vertices)}v {len(ob.data.polygons)}f "
f"nonmanifold_edges={nm} degenerate_faces={dg}")
# SKIP_CLEAN=1: the transplanted head's pristine look depends on its authored custom normals,
# and clean_mesh's normals_make_consistent re-winds nonmanifold shells — exactly the lash/eye
# geometry — which is how the master's eyes got broken in the first place. The cost of skipping
# is only cosmetic confetti islands from the body's 1,649 nonmanifold edges.
if os.environ.get("SKIP_CLEAN") != "1":
clean_mesh("after decimate")
else:
log("clean_mesh SKIPPED (SKIP_CLEAN=1) — protecting authored head normals")
me = ob.data
n_v = len(me.vertices)
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
lo, hi = co.min(axis=0), co.max(axis=0)
span = hi - lo
UP = int(np.argmax(span))
LR = int(np.argmax(np.where(np.arange(3) == UP, -1, span)))
FB = 3 - UP - LR
u = (co[:, UP] - lo[UP]) / span[UP]
s = co[:, LR] - 0.5 * (lo[LR] + hi[LR])
d = co[:, FB] - 0.5 * (lo[FB] + hi[FB])
HALF = 0.5 * span[LR]
sn = s / HALF # left-right, normalised to +-1
print(f"axes up={'xyz'[UP]} lr={'xyz'[LR]} fb={'xyz'[FB]} height {span[UP]:.4f} units")
# ---------------------------------------------------------------- landmarks
def radius_profile(mask, coord, lo_c, hi_c, nb):
"""mean in-slice radius vs coord, over `nb` bins — the narrow parts are the joints."""
ed = np.linspace(lo_c, hi_c, nb + 1)
mid = 0.5 * (ed[:-1] + ed[1:])
out = np.full(nb, np.nan)
for i in range(nb):
m = mask & (coord >= ed[i]) & (coord < ed[i + 1])
if m.sum() < 30:
continue
A = np.stack([s[m], d[m], u[m]], axis=1)
A = np.delete(A, 0 if coord is sn else 2, axis=1) if False else A
# radius measured in the two axes perpendicular to `coord`
if coord is u:
P = np.stack([s[m], d[m]], axis=1)
else:
P = np.stack([d[m], (u[m] - u[m].mean()) * span[UP]], axis=1)
out[i] = np.linalg.norm(P - P.mean(axis=0), axis=1).mean()
return mid, out
def local_min(mid, prof, lo_c, hi_c):
m = (mid >= lo_c) & (mid <= hi_c) & np.isfinite(prof)
if not m.any():
return 0.5 * (lo_c + hi_c)
return float(mid[m][np.argmin(prof[m])])
def taper_end(mid, prof, lo_c, hi_c, from_high, tol=0.08):
"""Where the limb stops tapering, taken from the EXTREMITY side.
The global minimum of the radius profile is not the joint: on the leg it sits up the shin,
well above where the foot ends. The joint is the end of the taper nearest the extremity —
the first bin (walking in from that side) that reaches within `tol` of the minimum.
"""
m = (mid >= lo_c) & (mid <= hi_c) & np.isfinite(prof)
if not m.any():
return 0.5 * (lo_c + hi_c)
mm, pp = mid[m], prof[m]
close = np.nonzero(pp <= pp.min() * (1.0 + tol))[0]
return float(mm[close[-1] if from_high else close[0]])
torso_side = np.abs(sn) < 0.30
mid_u, prof_u = radius_profile(torso_side, u, 0.0, 1.0, 60)
NECK_U = local_min(mid_u, prof_u, 0.80, 0.93)
# The ankle must be measured on ONE leg. Over both, the "radius" is really the gap between
# them, which falls monotonically from the feet up and has no minimum to find.
one_leg = (sn > 0.05) & (u < 0.35)
mid_l, prof_l = radius_profile(one_leg, u, 0.0, 0.35, 35)
print(" single-leg radius profile (u -> radius), the ankle is the narrow point:")
print(" " + " ".join(f"{m:.2f}:{r*1000:.0f}" for m, r in zip(mid_l, prof_l)
if np.isfinite(r)))
ANKLE_U = taper_end(mid_l, prof_l, 0.02, 0.14, from_high=False)
# shoulder: the smallest |s| whose slice is SHORT in u (an arm), scanning outward
absn = np.abs(sn)
ARM_IN = 0.35
for cut in np.arange(0.15, 0.60, 0.01):
m = (absn >= cut) & (absn < cut + 0.03)
if m.sum() < 30:
continue
if (u[m].max() - u[m].min()) < 0.13:
ARM_IN = float(cut)
break
mid_a, prof_a = radius_profile(absn > ARM_IN, absn, ARM_IN, 1.0, 40)
print(" arm radius profile (|s| -> radius), the wrist is the narrow point before the hand:")
print(" " + " ".join(f"{m:.2f}:{r*1000:.0f}" for m, r in zip(mid_a, prof_a)
if np.isfinite(r)))
# The profile reads: radius falls to the wrist, bulges again over the palm, then tapers down
# the fingers. Search below the palm bulge or the "wrist" lands among the fingers.
WRIST = taper_end(mid_a, prof_a, 0.60, 0.80, from_high=True)
# hip: the crotch is the HIGHEST strictly-empty midline slice. Measured on this mesh: the
# |sn|<0.025 strip is zero below u=0.42 and populated above — except one freak slice at
# 0.50-0.52 holding a single vertex, which is what tripped both threshold-based rules (v01's
# "<3 verts" and the density-scaled version) into calling mid-belly the crotch. No stray
# vertex can fake a STRICTLY empty slice, and there is no surface between the legs to put
# one there.
HIP_U = 0.45
for lv in np.arange(0.60, 0.20, -0.005):
m = (u >= lv) & (u < lv + 0.02) & (absn < 0.025)
if m.sum() == 0:
HIP_U = float(lv + 0.02)
break
print(f"LANDMARKS neck_u {NECK_U:.3f} hip_u {HIP_U:.3f} ankle_u {ANKLE_U:.3f} "
f"arm_in {ARM_IN:.3f} wrist {WRIST:.3f} (fractions of height / half-span)")
is_arm = absn > ARM_IN
is_hand = absn > WRIST
is_head = u > NECK_U
is_leg = (u < HIP_U) & ~is_arm
is_foot = u < ANKLE_U
# centre lines for the lengthwise cuts
# A constant centre works only for a perfectly axis-aligned limb. Hers droop, so a level set of
# u wanders off the arm and cuts it twice. Use a measured centre LINE: median u per |s| bin for
# the arms, median |s| per u bin for each leg, linearly interpolated.
def centre_line(mask, along, of, nb, lo_a, hi_a):
ed = np.linspace(lo_a, hi_a, nb + 1)
mid = 0.5 * (ed[:-1] + ed[1:])
val = np.full(nb, np.nan)
for i in range(nb):
m = mask & (along >= ed[i]) & (along < ed[i + 1])
if m.sum() >= 20:
val[i] = np.median(of[m])
ok = np.isfinite(val)
if ok.sum() < 2:
return lambda q: np.full_like(q, np.nanmedian(of[mask]) if mask.any() else 0.0)
mid_o, val_o = mid[ok], val[ok]
return lambda q: np.interp(np.asarray(q), mid_o, val_o)
arm_u_of = centre_line(is_arm & ~is_hand, absn, u, 24, ARM_IN, WRIST)
leg_s_of = {}
for sg in (-1, 1):
m = is_leg & ~is_foot & (np.sign(sn) == sg)
leg_s_of[sg] = centre_line(m, u, absn, 20, ANKLE_U, HIP_U)
# A foot cut at the ankle is an L (ankle-heel-toes) and a hand is a flat paddle; neither
# flattens as one chart. Split each along its silhouette, exactly where an artist would:
# the foot into upper/sole at its mid-height, the hand into back/palm at its mid-thickness.
U_SOLE = float(np.median(u[is_foot])) if is_foot.sum() else ANKLE_U * 0.5
D_PALM = float(np.median(d[is_hand])) if is_hand.sum() else 0.0
print(f" foot split at u {U_SOLE:.3f} (upper|sole) hand split at d {D_PALM:+.4f} (back|palm)")
qa = np.linspace(ARM_IN, WRIST, 5)
print(f" arm centre line u at |s|={np.round(qa,2).tolist()}: "
f"{np.round(arm_u_of(qa), 3).tolist()}")
ql = np.linspace(ANKLE_U, HIP_U, 5)
print(f" leg(+) centre line |s| at u={np.round(ql,2).tolist()}: "
f"{np.round(leg_s_of[1](ql), 3).tolist()}")
# The torso is a tube with FOUR holes (neck, two armholes, hip). The back midline joins neck to
# hip; two more cuts are needed or the chart stays multiply-connected and the unwrap stretches it
# badly. Run a relief cut across the BACK at armpit height, from each armhole in to the midline.
# upper body only — at |s| = ARM_IN the feet also splay out that far, and they win a p5
m_pit = (np.abs(absn - ARM_IN) < 0.03) & (u > 0.5)
U_PIT = float(np.percentile(u[m_pit], 5)) if m_pit.sum() > 30 else 0.62
print(f" armpit u {U_PIT:.3f} -> relief cut across the back at that height")
# ---------------------------------------------------------------- mark seams
bm = bmesh.new()
bm.from_mesh(me)
tally = {}
def hit(k):
tally[k] = tally.get(k, 0) + 1
return True
for e in bm.edges:
e.seam = False
for e in bm.edges:
a, b = e.verts[0].index, e.verts[1].index
# rings, in order of priority
if is_head[a] != is_head[b]:
e.seam = hit("neck ring"); continue
if is_hand[a] != is_hand[b]:
e.seam = hit("wrist rings"); continue
if is_foot[a] != is_foot[b]:
e.seam = hit("ankle rings"); continue
if is_arm[a] != is_arm[b]:
e.seam = hit("shoulder rings"); continue
if (u[a] < HIP_U) != (u[b] < HIP_U):
e.seam = hit("hip ring"); continue
# lengthwise cuts
if is_foot[a] and is_foot[b]:
if (u[a] > U_SOLE) != (u[b] > U_SOLE):
e.seam = hit("foot sole line"); continue
elif is_hand[a] and is_hand[b]:
if (d[a] > D_PALM) != (d[b] > D_PALM):
e.seam = hit("hand palm line"); continue
elif is_arm[a] and is_arm[b] and not (is_hand[a] or is_hand[b]):
ca, cb = arm_u_of(absn[a]), arm_u_of(absn[b])
if d[a] > 0 and d[b] > 0 and (u[a] > ca) != (u[b] > cb):
e.seam = hit("arm back line"); continue
elif is_leg[a] and is_leg[b] and not (is_foot[a] or is_foot[b]):
sg = int(1 if sn[a] + sn[b] >= 0 else -1)
if absn[a] < leg_s_of[sg](u[a]) and absn[b] < leg_s_of[sg](u[b]) \
and (d[a] > 0) != (d[b] > 0):
e.seam = hit("leg inner line"); continue
elif not (is_arm[a] or is_arm[b] or is_foot[a] or is_foot[b]):
# torso and head share one continuous back midline
if d[a] > 0 and d[b] > 0 and (sn[a] > 0) != (sn[b] > 0):
e.seam = hit("back midline"); continue
# armpit relief: back only, from each armhole inward to the midline
if d[a] > 0 and d[b] > 0 and not is_head[a] and not is_head[b] \
and (u[a] > U_PIT) != (u[b] > U_PIT):
e.seam = hit("armpit relief"); continue
for k in sorted(tally):
print(f" seam '{k}': {tally[k]} edges")
bm.to_mesh(me)
bm.free()
log(f"marked {sum(tally.values())} seam edges")
# ---------------------------------------------------------------- unwrap
UNWRAP_METHOD = os.environ.get("UNWRAP_METHOD", "MINIMUM_STRETCH")
def do_unwrap(pack):
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
# Angle-based (ABF) is conformal: it preserves angles and is free to crush area. At her folds
# and flaps that means whole patches land under one texel and come out untextured. The
# minimum-stretch (SLIM) solver optimises area distortion instead, which is what a texture
# transfer actually needs.
try:
bpy.ops.uv.unwrap(method=UNWRAP_METHOD, margin=0.0)
except TypeError:
log(f"unwrap method {UNWRAP_METHOD} unavailable — falling back to ANGLE_BASED")
bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=0.0)
bpy.ops.uv.average_islands_scale()
if pack:
try:
bpy.ops.uv.pack_islands(rotate=True, margin=0.003, scale=True)
except TypeError:
bpy.ops.uv.pack_islands(margin=0.003)
bpy.ops.object.mode_set(mode='OBJECT')
def get_islands():
m_ = ob.data
n_l_, n_f_ = len(m_.loops), len(m_.polygons)
lv = np.empty(n_l_, dtype=np.int32); m_.loops.foreach_get("vertex_index", lv)
uv_ = np.empty(n_l_ * 2); m_.uv_layers.active.data.foreach_get("uv", uv_)
uv_ = uv_.reshape(-1, 2)
ls = np.empty(n_f_, dtype=np.int32); m_.polygons.foreach_get("loop_start", ls)
lt = np.empty(n_f_, dtype=np.int32); m_.polygons.foreach_get("loop_total", lt)
li_ = ls[lt == 3]
Q = 1 << 20
k = (lv.astype(np.int64) * Q * Q
+ np.round(np.clip(uv_[:, 0], 0, 1) * (Q - 1)).astype(np.int64) * Q
+ np.round(np.clip(uv_[:, 1], 0, 1) * (Q - 1)).astype(np.int64))
_, uvv_ = np.unique(k, return_inverse=True)
n_uvv_ = uvv_.max() + 1
T_ = np.stack([uvv_[li_], uvv_[li_ + 1], uvv_[li_ + 2]], axis=1)
par = np.arange(n_uvv_, dtype=np.int64)
def find(x):
r = x
while par[r] != r:
r = par[r]
while par[x] != r:
par[x], x = r, par[x]
return r
for a_, b_, c_ in T_:
ra, rb, rc = find(a_), find(b_), find(c_)
if ra != rb:
par[rb] = ra
if ra != rc:
par[rc] = ra
_, isl_ = np.unique(np.array([find(i) for i in range(n_uvv_)]), return_inverse=True)
return isl_, isl_[T_[:, 0]], li_, lv, uv_, isl_.max() + 1
# Keep the source UVs on a second layer. Stage 31 needs them to resample the existing maps into
# the new layout — unwrapping over them in place would throw the textures away.
src_layer = ob.data.uv_layers.active
src_layer.name = "UVMap_tripo"
new_layer = ob.data.uv_layers.new(name="UVMap_atlas", do_init=True)
ob.data.uv_layers.active = new_layer
for i, l in enumerate(ob.data.uv_layers):
if l is new_layer:
ob.data.uv_layers.active_index = i
log(f"uv layers: {[l.name for l in ob.data.uv_layers]} active="
f"{ob.data.uv_layers.active.name}")
HEAD_BOOST = float(os.environ.get("HEAD_BOOST", "1.5"))
# EXACTLY the seam threshold: with any offset, faces between the neck seam and the selection
# line end up in islands that are half re-unwrapped and half Tripo — incoherent charts.
NECK_FACE = NECK_U
# UVMap_atlas was created with do_init=True, i.e. a copy of the Tripo layer — so faces we do
# NOT unwrap keep their original mapping. Select body faces only, by every-corner test.
import bmesh as _bm
bpy.ops.object.mode_set(mode='EDIT')
# FACE select mode, or per-face .select is flushed away by vertex-mode sync and the unwrap
# silently runs on a leftover fraction of the body (measured: 9,757 of ~56k faces).
bpy.ops.mesh.select_mode(type='FACE')
bpy.ops.mesh.select_all(action='DESELECT')
bmm = _bm.from_edit_mesh(ob.data)
bmm.verts.ensure_lookup_table()
zs = [v.co.z for v in bmm.verts]
zlo_, zhi_ = min(zs), max(zs)
vhead = [((v.co.z - zlo_) / (zhi_ - zlo_)) > NECK_FACE for v in bmm.verts]
nbody = 0
for f in bmm.faces:
sel = not any(vhead[v.index] for v in f.verts)
f.select = sel
nbody += int(sel)
_bm.update_edit_mesh(ob.data)
log(f"body-only unwrap: {nbody} faces selected, head keeps Tripo UVs")
try:
bpy.ops.uv.unwrap(method='MINIMUM_STRETCH', margin=0.0)
except TypeError:
bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=0.0)
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.uv.average_islands_scale()
bpy.ops.object.mode_set(mode='OBJECT')
# boost the head charts before packing: scale each majority-head island about its centroid
isl, fisl, li, loops_v, uv, n_isl = get_islands()
me2 = ob.data
co2 = np.empty(len(me2.vertices) * 3); me2.vertices.foreach_get("co", co2); co2 = co2.reshape(-1, 3)
u2 = (co2[:, 2] - co2[:, 2].min()) / (co2[:, 2].max() - co2[:, 2].min())
tri_head = u2[loops_v[li]] > NECK_FACE
head_isl = set()
for i in range(n_isl):
m = fisl == i
if m.sum() and tri_head[m].mean() > 0.5:
head_isl.add(i)
uvl2 = me2.uv_layers["UVMap_atlas"]
buf = np.empty(len(me2.loops) * 2); uvl2.data.foreach_get("uv", buf)
buf = buf.reshape(-1, 2)
# island id per loop: quantized (vertex,uv) identity -> island, same construction as get_islands
loop_isl = np.full(len(me2.loops), -1, dtype=np.int64)
loop_isl[li] = fisl
loop_isl[li + 1] = fisl
loop_isl[li + 2] = fisl
boosted = 0
for i in head_isl:
m = loop_isl == i
c = buf[m].mean(axis=0)
buf[m] = c + (buf[m] - c) * HEAD_BOOST
boosted += 1
uvl2.data.foreach_set("uv", buf.reshape(-1))
log(f"boosted {boosted} head islands x{HEAD_BOOST}")
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
try:
bpy.ops.uv.pack_islands(rotate=True, margin=0.003, scale=True)
except TypeError:
bpy.ops.uv.pack_islands(margin=0.003)
bpy.ops.object.mode_set(mode='OBJECT')
isl, fisl, li, loops_v, uv, n_isl = get_islands()
log("unwrapped + packed (body SLIM, head Tripo)")
tuv = np.stack([np.clip(uv, 0, 1)[li], np.clip(uv, 0, 1)[li + 1], np.clip(uv, 0, 1)[li + 2]], 1)
auv = 0.5 * np.abs((tuv[:, 1, 0] - tuv[:, 0, 0]) * (tuv[:, 2, 1] - tuv[:, 0, 1])
- (tuv[:, 2, 0] - tuv[:, 0, 0]) * (tuv[:, 1, 1] - tuv[:, 0, 1]))
me = ob.data
co = np.empty(len(me.vertices) * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
P3 = co[np.stack([loops_v[li], loops_v[li + 1], loops_v[li + 2]], axis=1)]
a3 = 0.5 * np.linalg.norm(np.cross(P3[:, 1] - P3[:, 0], P3[:, 2] - P3[:, 0]), axis=1)
isl_auv = np.bincount(fisl, weights=auv, minlength=n_isl)
isl_a3 = np.bincount(fisl, weights=a3, minlength=n_isl)
isl_nf = np.bincount(fisl, minlength=n_isl)
UNITM = 1.777 / span[UP]
W = 4096
dens = np.where(isl_a3 > 0, np.sqrt(np.maximum(isl_auv, 0) / np.maximum(isl_a3, 1e-12))
* W / (UNITM * 1000.0), np.nan)
o = np.argsort(-isl_auv)
cum = np.cumsum(isl_auv[o]) / max(isl_auv.sum(), 1e-12)
n99 = int(np.searchsorted(cum, 0.99)) + 1
print(f"\n=== NEW ATLAS (Tripo baseline in brackets) ===")
print(f"islands {n_isl} [5870] 99%-of-area islands {n99} [84]")
print(f"coverage {isl_auv.sum()*100:.1f}% [62.0] confetti <20 faces {int((isl_nf<20).sum())} [5763]")
db = dens[o[:n99]]; db = db[np.isfinite(db)]
print(f"texel density {db.min():.2f}..{db.max():.2f} px/mm -> {db.max()/max(db.min(),1e-9):.2f}x "
f"spread [1.8x]")
# distortion, measured only over the real charts — the confetti islands are degenerate
# triangles whose ratios are meaningless and would own the tail
real = np.isin(fisl, o[:n99])
ok = real & (a3 > 1e-12) & (auv > 1e-14)
sc = np.sqrt(auv[ok] / a3[ok]); sc /= np.median(sc)
print(f"per-triangle area-scale vs median, real charts only: p05 {np.percentile(sc,5):.2f} "
f"p95 {np.percentile(sc,95):.2f} p99 {np.percentile(sc,99):.2f} (1.0 = undistorted)")
print("\nthe charts: # faces uv_area% 3D cm2 stretch_p95 where")
for i in o[:16]:
if isl_auv[i] * 100 < 0.05:
break
m = fisl == i
mo = m & (a3 > 1e-12) & (auv > 1e-14)
st = np.sqrt(auv[mo] / a3[mo])
st = st / np.median(st) if len(st) else np.array([1.0])
vs = np.unique(np.stack([loops_v[li], loops_v[li + 1], loops_v[li + 2]], 1)[m].ravel())
lab = []
for nm, msk in (("head", is_head), ("hand", is_hand), ("foot", is_foot),
("arm", is_arm & ~is_hand), ("leg", is_leg & ~is_foot)):
if msk[vs].mean() > 0.6:
lab.append(nm)
side = "L" if sn[vs].mean() > 0.15 else ("R" if sn[vs].mean() < -0.15 else "mid")
print(f" {i:6d} {isl_nf[i]:8d} {isl_auv[i]*100:9.3f} {isl_a3[i]*UNITM*UNITM*1e4:9.1f} "
f"{np.percentile(st,95):11.2f} {'+'.join(lab) or 'torso'} {side}")
# picture
R = 1024
rng = np.random.RandomState(3)
pal = rng.rand(n_isl, 3) * 0.7 + 0.25
IS = np.zeros((R, R, 3))
tp = tuv * (R - 1)
for fi in range(len(tp)):
P = tp[fi]
x0, x1 = int(P[:, 0].min()), int(np.ceil(P[:, 0].max()))
y0, y1 = int(P[:, 1].min()), int(np.ceil(P[:, 1].max()))
if x1 < x0 or y1 < y0 or x1 - x0 > 64 or y1 - y0 > 64:
continue
dt = ((P[1, 1] - P[2, 1]) * (P[0, 0] - P[2, 0]) + (P[2, 0] - P[1, 0]) * (P[0, 1] - P[2, 1]))
if abs(dt) < 1e-12:
continue
gx, gy = np.meshgrid(np.arange(x0, min(x1, R - 1) + 1), np.arange(y0, min(y1, R - 1) + 1))
aa = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / dt
bb = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / dt
ins = (aa >= 0) & (bb >= 0) & (1 - aa - bb >= 0)
if ins.any():
IS[gy[ins], gx[ins]] = pal[fisl[fi]]
img = bpy.data.images.new("isl", R, R, alpha=False)
A = np.ones((R, R, 4), dtype=np.float32); A[:, :, :3] = IS
img.pixels.foreach_set(A.reshape(-1))
p = os.path.join(OUTDIR, "charts.png")
img.file_format = 'PNG'; img.filepath_raw = p; img.save(filepath=p)
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUTDIR, "seamed.blend"))
log(f"wrote {p} + seamed.blend")
print("SEAMS_DONE")
+310
View File
@@ -0,0 +1,310 @@
# Stage 51 (v02): 31_reatlas.py + centroid splats for sub-texel triangles.
#
# The head-protected v02 body keeps Tripo's eyelash/eye/lip SHELLS at full density — thousands of
# triangles smaller than one texel. The v01 transfer just skipped those (0.58% of surface) and let
# the padding pass cover them, which was invisible when the skipped area was random slivers. Here
# the skipped set IS the lashes: they rendered as grey glass because their texels held padding
# smear instead of lash. Every sub-texel triangle now SPLATS its centroid: one point-sample of the
# source maps written to its one destination texel — so a lash texel holds lash.
# Stage 31: resample the existing maps into the new anatomical atlas, then export the re-atlased
# body.
#
# blender --background --python 31_reatlas.py -- <seamed.blend> <out_dir> <out.glb>
#
# Input is 24_seams.py's output, which carries TWO uv layers: 'UVMap_tripo' (the source 5,870-chart
# atlas, still holding the textures) and 'UVMap_atlas' (the new ~14-chart layout, empty). This walks
# every triangle in NEW atlas space, barycentrically recovers the OLD uv per texel, and samples the
# source maps there. Same mesh, same triangles, so the transfer is exact — no projection, no BVH,
# no Cycles bake.
#
# The normal map cannot simply be copied. It is tangent-space, and re-atlasing rotates (and
# sometimes mirrors) every chart, so its frame moves. Per face this computes the old and new
# tangent frames about the shared geometric normal and rotates the stored vector between them;
# skipping that would light her detail from the wrong direction, subtly and everywhere.
import bpy, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUTDIR, OUTGLB = argv[0], os.path.abspath(argv[1]), os.path.abspath(argv[2])
os.makedirs(OUTDIR, exist_ok=True)
RES = int(argv[3]) if len(argv) > 3 else 4096
PAD = 16
t0 = time.time()
def log(m):
print(f"[atlas {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))
me = ob.data
names = [l.name for l in me.uv_layers]
assert "UVMap_tripo" in names and "UVMap_atlas" in names, f"expected both uv layers, got {names}"
n_v, n_l, n_f = len(me.vertices), len(me.loops), len(me.polygons)
log(f"mesh {n_v}v {n_f}f uv layers {names}")
# ---- source maps, resolved through the material graph (the file holds stale duplicates) ----
src = {}
for slot in ob.material_slots:
mat = slot.material
if not mat or not mat.node_tree:
continue
for node in mat.node_tree.nodes:
if node.type != 'BSDF_PRINCIPLED':
continue
for sock, key in (("Base Color", "base"), ("Normal", "normal"), ("Roughness", "rm")):
if sock not in node.inputs or not node.inputs[sock].links:
continue
nd = node.inputs[sock].links[0].from_node
seen = set()
while nd and nd.type != 'TEX_IMAGE' and id(nd) not in seen:
seen.add(id(nd))
nxt = None
for i in nd.inputs:
if i.links:
nxt = i.links[0].from_node
break
nd = nxt
if nd and nd.type == 'TEX_IMAGE' and nd.image:
src[key] = nd.image
for k, im in src.items():
log(f"source {k}: '{im.name}' {im.size[0]}x{im.size[1]}")
assert "base" in src, "no basecolor found"
def px_of(img):
w, h = img.size
b = np.empty(w * h * 4, dtype=np.float32)
img.pixels.foreach_get(b)
return b.reshape(h, w, 4)[:, :, :3].astype(np.float32), w, h
# Colour management is not cosmetic here. `image.pixels` hands back linear values for an sRGB
# image and raw values for a Non-Color one, and re-encodes the same way on save. Write raw
# normal/rm data into a default (sRGB) image and saving gamma-encodes it: 0.5 comes back as 0.21,
# so every stored normal becomes a large false perturbation and the body shades dark and glossy.
# Each new map must therefore inherit its source's colorspace exactly.
for k, im in src.items():
log(f" {k} colorspace: {im.colorspace_settings.name}")
# ---- geometry + both uv sets ----
loops_v = np.empty(n_l, dtype=np.int32); me.loops.foreach_get("vertex_index", loops_v)
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
l_start = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_start", l_start)
l_tot = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_total", l_tot)
li = l_start[l_tot == 3]
nT = len(li)
def uv_of(name):
a = np.empty(n_l * 2)
me.uv_layers[name].data.foreach_get("uv", a)
return a.reshape(-1, 2)
UO = uv_of("UVMap_tripo")
UN = uv_of("UVMap_atlas")
IDX = np.stack([li, li + 1, li + 2], axis=1)
VI = loops_v[IDX] # (T,3) vertex index
Qo = np.clip(UO[IDX], 0.0, 1.0) # (T,3,2) source uv
Pn = np.clip(UN[IDX], 0.0, 1.0) # (T,3,2) new uv
P3 = co[VI] # (T,3,3)
log(f"{nT} triangles")
# ---- per-face tangent frames, for the normal-map rotation ----
e1 = P3[:, 1] - P3[:, 0]
e2 = P3[:, 2] - P3[:, 0]
N = np.cross(e1, e2)
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-20)
def tangent(Q):
d1 = Q[:, 1] - Q[:, 0]
d2 = Q[:, 2] - Q[:, 0]
det = d1[:, 0] * d2[:, 1] - d2[:, 0] * d1[:, 1]
r = np.where(np.abs(det) < 1e-20, 0.0, 1.0 / np.where(det == 0, 1.0, det))
T = (e1 * d2[:, 1:2] - e2 * d1[:, 1:2]) * r[:, None]
B = (e2 * d1[:, 0:1] - e1 * d2[:, 0:1]) * r[:, None]
# orthonormalise against the shared geometric normal
T = T - N * (N * T).sum(axis=1, keepdims=True)
ln = np.linalg.norm(T, axis=1, keepdims=True)
bad = (ln[:, 0] < 1e-12)
T = np.where(bad[:, None], np.cross(N, [0.0, 0.0, 1.0]), T / np.maximum(ln, 1e-20))
ln = np.maximum(np.linalg.norm(T, axis=1, keepdims=True), 1e-20)
T = T / ln
w = np.sign((np.cross(N, T) * B).sum(axis=1))
w = np.where(w == 0, 1.0, w)
return T, np.cross(N, T) * w[:, None]
To, Bo = tangent(Qo)
Tn, Bn = tangent(Pn)
# (nx',ny') = M . (nx,ny) — both frames share N, so nz is unchanged
M00 = (To * Tn).sum(axis=1); M01 = (Bo * Tn).sum(axis=1)
M10 = (To * Bn).sum(axis=1); M11 = (Bo * Bn).sum(axis=1)
rot = np.degrees(np.arctan2(M10, M00))
log(f"tangent frame rotation between atlases: p50 {np.percentile(np.abs(rot),50):.1f} deg, "
f"p95 {np.percentile(np.abs(rot),95):.1f} deg, max {np.abs(rot).max():.1f} deg")
# ---- rasterise the new atlas ----
W = H = RES
out = {k: np.zeros((H, W, 3), dtype=np.float32) for k in src}
srcpx = {k: px_of(v) for k, v in src.items()}
mask = np.zeros((H, W), dtype=bool)
def bilinear(A, w, h, uu, vv):
x = np.clip(uu, 0, 1) * (w - 1)
y = np.clip(vv, 0, 1) * (h - 1)
x0 = np.floor(x).astype(np.int32); y0 = np.floor(y).astype(np.int32)
x1 = np.minimum(x0 + 1, w - 1); y1 = np.minimum(y0 + 1, h - 1)
fx = (x - x0)[:, None]; fy = (y - y0)[:, None]
return (A[y0, x0] * (1 - fx) * (1 - fy) + A[y0, x1] * fx * (1 - fy)
+ A[y1, x0] * (1 - fx) * fy + A[y1, x1] * fx * fy)
Ppx = Pn * (W - 1)
TOL = -0.02 # slight over-rasterisation so charts have no interior gaps
done = 0
hitlist = []
for f in range(nT):
P = Ppx[f]
x0 = int(P[:, 0].min()); x1 = int(np.ceil(P[:, 0].max()))
y0 = int(P[:, 1].min()); y1 = int(np.ceil(P[:, 1].max()))
if x1 < x0 or y1 < y0 or (x1 - x0) > 512 or (y1 - y0) > 512:
continue
det = ((P[1, 1] - P[2, 1]) * (P[0, 0] - P[2, 0])
+ (P[2, 0] - P[1, 0]) * (P[0, 1] - P[2, 1]))
if abs(det) < 1e-12:
continue
gx, gy = np.meshgrid(np.arange(max(x0, 0), min(x1, W - 1) + 1),
np.arange(max(y0, 0), min(y1, H - 1) + 1))
if gx.size == 0:
continue
a = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / det
b = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / det
c = 1.0 - a - b
ins = (a >= TOL) & (b >= TOL) & (c >= TOL)
if not ins.any():
# sub-texel triangle: splat its centroid instead of abandoning it to the padding pass
cx = int(np.clip(round(float(P[:, 0].mean())), 0, W - 1))
cy = int(np.clip(round(float(P[:, 1].mean())), 0, H - 1))
ou = np.array([Qo[f, :, 0].mean()])
ov = np.array([Qo[f, :, 1].mean()])
for k, (A, w, h) in srcpx.items():
val = bilinear(A, w, h, ou, ov)
if k == "normal":
nx = val[:, 0] * 2.0 - 1.0
ny = val[:, 1] * 2.0 - 1.0
val = np.stack([(nx * M00[f] + ny * M01[f]) * 0.5 + 0.5,
(nx * M10[f] + ny * M11[f]) * 0.5 + 0.5,
val[:, 2]], axis=1)
out[k][cy, cx] = val[0]
mask[cy, cx] = True
hitlist.append(f)
done += 1
continue
# Write the slightly-outside rim pixels (TOL) but SAMPLE from strictly inside the source
# triangle — the old atlas is 38% empty, and extrapolating past a source triangle's edge
# reads black gap and leaves a dark fringe on every chart border.
aa = np.clip(a[ins], 0.0, 1.0); bb = np.clip(b[ins], 0.0, 1.0); cc = np.clip(c[ins], 0.0, 1.0)
tot = np.maximum(aa + bb + cc, 1e-12)
aa, bb, cc = aa / tot, bb / tot, cc / tot
ou = aa * Qo[f, 0, 0] + bb * Qo[f, 1, 0] + cc * Qo[f, 2, 0]
ov = aa * Qo[f, 0, 1] + bb * Qo[f, 1, 1] + cc * Qo[f, 2, 1]
yy, xx = gy[ins], gx[ins]
for k, (A, w, h) in srcpx.items():
val = bilinear(A, w, h, ou, ov)
if k == "normal":
nx = val[:, 0] * 2.0 - 1.0
ny = val[:, 1] * 2.0 - 1.0
val = np.stack([(nx * M00[f] + ny * M01[f]) * 0.5 + 0.5,
(nx * M10[f] + ny * M11[f]) * 0.5 + 0.5,
val[:, 2]], axis=1)
out[k][yy, xx] = val
mask[yy, xx] = True
hitlist.append(f)
done += 1
skipped = np.ones(nT, dtype=bool)
skipped[hitlist] = False
a3 = 0.5 * np.linalg.norm(np.cross(e1, e2), axis=1)
UNITM = 1.777 / (co[:, 2].max() - co[:, 2].min())
log(f"rasterised {done}/{nT} triangles -> {int(mask.sum())} texels "
f"({100.0*mask.sum()/(W*H):.1f}% of the atlas)")
log(f"skipped {int(skipped.sum())} triangles holding "
f"{a3[skipped].sum()*UNITM*UNITM*1e4:.2f} cm2 of "
f"{a3.sum()*UNITM*UNITM*1e4:.0f} cm2 total ({100.0*a3[skipped].sum()/a3.sum():.3f}%) "
f"— they are sub-texel slivers, covered by the padding pass")
# ---- pad outward so filtering and mip generation never pull in empty space ----
have = mask.copy()
for k in out:
C = out[k]
hv = mask.copy()
for _ in range(PAD):
acc = np.zeros_like(C)
wac = np.zeros((H, W), dtype=np.float32)
Wf = hv.astype(np.float32)
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
acc += np.roll(C * Wf[:, :, None], (dy, dx), axis=(0, 1))
wac += np.roll(Wf, (dy, dx), axis=(0, 1))
new = (~hv) & (wac > 0)
if not new.any():
break
C[new] = acc[new] / wac[new, None]
hv |= new
have = hv
log(f"padded to {int(have.sum())} texels ({100.0*have.sum()/(W*H):.1f}%)")
# ---- write the maps, rewire the material, drop the source uv layer ----
newimg = {}
for k in out:
im = bpy.data.images.new(f"lena_nude_atlas_{k}", W, H, alpha=False,
is_data=(src[k].colorspace_settings.name != 'sRGB'))
im.colorspace_settings.name = src[k].colorspace_settings.name
buf = np.ones((H, W, 4), dtype=np.float32)
buf[:, :, :3] = np.clip(out[k], 0, 1)
im.pixels.foreach_set(buf.reshape(-1))
im.file_format = 'JPEG' # the source maps are JPEG; match them so the GLB stays small
p = os.path.join(OUTDIR, f"lena_nude_atlas_{k}.jpg")
im.filepath_raw = p
im.save(filepath=p)
im.pack()
newimg[k] = im
log(f"wrote {p}")
for slot in ob.material_slots:
mat = slot.material
if not mat or not mat.node_tree:
continue
for node in mat.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image:
for k, old in src.items():
if node.image == old:
node.image = newimg[k]
# Ship the body double-sided. The glTF material arrives single-sided, so Blender culls backfaces —
# and this mesh has patches wound inward (locally CONSISTENT, so `normals_make_consistent` cannot
# see them and settles at 25 stray triangles). Culled, those patches read as grey holes across her
# face and underbust in every render. Disabling culling here exports doubleSided=true and they
# disappear. Backface culling buys a character body essentially nothing.
for slot in ob.material_slots:
if slot.material:
slot.material.use_backface_culling = False
log(f"material '{slot.material.name}' -> doubleSided")
me.uv_layers.active = me.uv_layers["UVMap_atlas"]
me.uv_layers.remove(me.uv_layers["UVMap_tripo"])
me.uv_layers["UVMap_atlas"].name = "UVMap"
log(f"uv layers now {[l.name for l in me.uv_layers]}")
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUTDIR, "reatlased.blend"))
for o in bpy.data.objects:
o.select_set(o is ob)
bpy.context.view_layer.objects.active = ob
bpy.ops.export_scene.gltf(filepath=OUTGLB, export_format='GLB', use_selection=True,
export_image_format='AUTO', export_jpeg_quality=95,
export_yup=True, export_apply=False)
log(f"EXPORTED {OUTGLB} ({os.path.getsize(OUTGLB)/1e6:.1f} MB)")
print("REATLAS_DONE")
+176
View File
@@ -0,0 +1,176 @@
# Stage 52 (v02): transplant the PRISTINE ORIGINAL head onto the decimated nude body.
#
# blender --background --python 52_head_transplant.py -- <body.blend> <out.blend>
#
# Why: bisecting the shard-eye defect (head renders per stage) proved it is in the hires MASTER,
# not 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 currently shipped game body. The one place the eyes are right is the
# untouched original, so the head comes from there — geometry, normals, UVs and all.
#
# Why a cut works HERE and not at the underwear: the bra had no skin beneath it (ray census,
# _Bare.glb's black cavity). The throat is ordinary skin on both sides of a plane, so both meshes
# take a clean planar bisect and the two rings bridge.
#
# The two lineages keep their own materials: body faces sample the master's maps (with the nude
# fill), head faces sample the ORIGINAL maps — the transfer stage resolves images per material.
# Bridge faces are new geometry with no source UVs; each takes a single nearby body-ring UV
# (all three corners the same texel), i.e. a flat splat of throat skin, invisible on a uniform
# throat and never smearing across two different atlases.
import bpy, bmesh, sys, os, time
import numpy as np
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
BODY_BLEND, OUT = argv[0], argv[1]
ORIG_GLB = r"C:\Users\Jeremy\tinqs\animation\characters\originals\female\female_lena_tripo.glb"
# TX_ZCUT env overrides the plane. The default 0.775 is what the SHIPPED hybrid used and must
# keep reproducing it — but that plane clips the T-POSE ARMS, giving five boundary rings, and at
# full density bridge_loops paired the wrong ones (a geometry shelf flared out of the shoulders).
# For full-res builds use 0.80: pure throat, exactly one ring per side.
Z_CUT = float(os.environ.get("TX_ZCUT", "0.775"))
t0 = time.time()
def log(m):
print(f"[tx {time.time()-t0:6.1f}s] {m}", flush=True)
def bisect_keep(ob, keep_above):
bpy.ops.object.select_all(action='DESELECT')
ob.select_set(True)
bpy.context.view_layer.objects.active = ob
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
# plane normal is +z, so OUTER = above the cut. clear_outer removes the ABOVE side —
# the first run had these inverted and stitched the shard head onto the clothed body.
bpy.ops.mesh.bisect(plane_co=(0, 0, Z_CUT), plane_no=(0, 0, 1),
clear_inner=keep_above, clear_outer=not keep_above, use_fill=False)
bpy.ops.object.mode_set(mode='OBJECT')
bpy.ops.wm.open_mainfile(filepath=BODY_BLEND)
body = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
log(f"body in: {len(body.data.vertices)} v")
bisect_keep(body, keep_above=False)
log(f"body below z={Z_CUT}: {len(body.data.vertices)} v")
# give the body a custom-normals layer BEFORE joining, or join drops the head's authored ones
body.data.shade_smooth()
try:
ln = np.empty(len(body.data.loops) * 3, dtype=np.float32)
body.data.corner_normals.foreach_get("vector", ln)
body.data.normals_split_custom_set(ln.reshape(-1, 3))
except Exception as e:
log(f" custom-normal seed on body: {e}")
before = {o.name for o in bpy.data.objects}
bpy.ops.import_scene.gltf(filepath=ORIG_GLB)
new = [o for o in bpy.data.objects if o.name not in before]
# drop importer widgets (glTF_not_exported) and non-meshes
for o in list(new):
if o.type != 'MESH' or any(c.name.startswith("glTF_not_exported") for c in o.users_collection):
if o.type == 'MESH' or o.type == 'ARMATURE' or o.type == 'EMPTY':
bpy.data.objects.remove(o, do_unlink=True)
new.remove(o)
head = max([o for o in new if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
bpy.ops.object.select_all(action='DESELECT')
head.select_set(True)
bpy.context.view_layer.objects.active = head
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
log(f"original in: {len(head.data.vertices)} v")
bisect_keep(head, keep_above=True)
log(f"original above z={Z_CUT}: {len(head.data.vertices)} v")
# BOTH sides must share one UV layer name or join makes two half-empty layers and the head
# samples texel (0,0) — measured: the whole head rendered as clay. The body blend's layer is
# 'UVMap' (34_v04 lineage); rename both to UVMap_tripo so join merges them into one.
for l in head.data.uv_layers:
l.name = "UVMap_tripo"
for l in body.data.uv_layers:
l.name = "UVMap_tripo"
# remember a body-side ring UV for the bridge splat
bm = bmesh.new(); bm.from_mesh(body.data)
uvl = bm.loops.layers.uv.get("UVMap_tripo") or bm.loops.layers.uv.active
ring_uv = None
for v in bm.verts:
if abs(v.co.z - Z_CUT) < 1e-4 and v.link_loops:
# take the uv of a loop one step AWAY from the ring (interior throat texel)
for l_ in v.link_loops:
ring_uv = tuple(l_[uvl].uv)
break
if ring_uv:
break
bm.free()
log(f"bridge splat uv: {ring_uv}")
# join: body is the target so its material stays slot 0; head material appends as slot 1
n_faces_before_join = len(body.data.polygons)
bpy.ops.object.select_all(action='DESELECT')
body.select_set(True)
head.select_set(True)
bpy.context.view_layer.objects.active = body
bpy.ops.object.join()
me = body.data
log(f"joined: {len(me.vertices)} v, {len(me.polygons)} f, materials {[m.name for m in me.materials]}")
# bridge the two rings
n_faces_before_bridge = len(me.polygons)
bm = bmesh.new(); bm.from_mesh(me)
bm.edges.ensure_lookup_table()
ring_edges = [e for e in bm.edges
if len(e.link_faces) == 1
and abs(e.verts[0].co.z - Z_CUT) < 1e-4 and abs(e.verts[1].co.z - Z_CUT) < 1e-4]
# how many separate rings? more than 2 means the plane clipped limbs and the bridge will pair wrong
_par = {}
def _find(a):
while _par.get(a, a) != a:
_par[a] = _par.get(_par[a], _par[a]); a = _par[a]
return a
for e in ring_edges:
a, b_ = _find(e.verts[0].index), _find(e.verts[1].index)
if a != b_: _par[a] = b_
_roots = {_find(e.verts[0].index) for e in ring_edges}
log(f"ring boundary edges: {len(ring_edges)} in {len(_roots)} loops"
+ (" <-- WARNING: >2 loops, plane clips limbs" if len(_roots) > 2 else ""))
try:
res = bmesh.ops.bridge_loops(bm, edges=ring_edges)
new_faces = res.get("faces", [])
except Exception as e:
log(f"bridge_loops failed: {e}")
new_faces = []
log(f"bridge created {len(new_faces)} faces")
uvl = bm.loops.layers.uv.get("UVMap_tripo") or bm.loops.layers.uv.active
for f in new_faces:
f.material_index = 0 # body material: throat skin
f.smooth = True
for l_ in f.loops:
l_[uvl].uv = ring_uv # flat splat of one throat texel
bm.to_mesh(me)
bm.free()
me.update()
# One material for the whole mesh. The transfer stage resamples every map into ONE new atlas,
# so a second material would leave head faces pointing at untransferred images with replaced
# UVs. Sampling the head from the MASTER's maps is exact: every texture pass in the lane masked
# the head out, so its texels are the original's bit-for-bit.
mi = np.zeros(len(me.polygons), dtype=np.int32)
me.polygons.foreach_set("material_index", mi)
while len(me.materials) > 1:
me.materials.pop(index=1)
log(f"unified material: {[m.name for m in me.materials]}")
# verify
co = np.empty(len(me.vertices) * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
bm = bmesh.new(); bm.from_mesh(me)
open_ring = [e for e in bm.edges if len(e.link_faces) == 1
and abs(e.verts[0].co.z - Z_CUT) < 1e-3]
bm.free()
print(f"\nVERIFY: {len(me.vertices)} v / {len(me.polygons)} f")
print(f" height {co[:,2].max()-co[:,2].min():.5f} (feet {co[:,2].min():+.5f})")
print(f" open boundary edges left at the cut: {len(open_ring)} (0 = ring fully bridged)")
print(f" materials: {[m.name for m in me.materials]}")
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("TRANSPLANT_DONE")
+98
View File
@@ -0,0 +1,98 @@
# Stage 53 (v02): rig the transplant body by transferring weights from the rigged v01.
#
# blender --background --python 53_rig_transfer.py -- <reatlased.blend> <rig_v01.blend> <out.blend> <out.glb>
#
# The v02 topology is new (head transplant + re-decimation), so the index-exact graft that rigged
# v01 is off the table. Nearest-surface transfer is safe HERE because source and target are the
# SAME body in the SAME frame (v01 was decimated from the same master this body's torso came
# from): every target vertex sits on or microns from the source surface. The July finger-mangling
# happened transferring decimated->FULL-RES fingers; v02's hands are at v01's own density, and
# the full-res head takes trivially rigid weights (Head + face bones).
#
# Verification is the same standard as the graft: weights must sum to 1 with zero unweighted
# verts, and the DEFORMED rest pose must sit exactly on the undeformed mesh.
import bpy, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
TARGET, RIGSRC, OUTB, OUTG = argv[0], argv[1], os.path.abspath(argv[2]), os.path.abspath(argv[3])
t0 = time.time()
def log(m):
print(f"[rig53 {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=TARGET)
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)
V_ref = np.empty(n * 3); me.vertices.foreach_get("co", V_ref); V_ref = V_ref.reshape(-1, 3)
UNITM = 1.777 / (V_ref[:, 2].max() - V_ref[:, 2].min())
log(f"target: {n} v")
with bpy.data.libraries.load(RIGSRC, link=False) as (src, dst):
dst.objects = src.objects
donor, arm = None, None
for o in dst.objects:
if o is None:
continue
bpy.context.scene.collection.objects.link(o)
if o.type == 'MESH':
donor = o
if o.type == 'ARMATURE':
arm = o
log(f"donor: {len(donor.data.vertices)} v, {len(donor.vertex_groups)} groups; "
f"armature: {len(arm.data.bones)} bones")
# transfer weights nearest-surface
for vg in list(body.vertex_groups):
body.vertex_groups.remove(vg)
bpy.context.view_layer.objects.active = body
dt = body.modifiers.new("wts", 'DATA_TRANSFER')
dt.object = donor
dt.use_vert_data = True
dt.data_types_verts = {'VGROUP_WEIGHTS'}
dt.vert_mapping = 'POLYINTERP_NEAREST'
dt.layers_vgroup_select_src = 'ALL'
bpy.ops.object.datalayout_transfer(modifier=dt.name)
bpy.ops.object.modifier_apply(modifier=dt.name)
log(f"weights transferred into {len(body.vertex_groups)} groups")
bpy.ops.object.vertex_group_limit_total(group_select_mode='ALL', limit=4)
bpy.ops.object.vertex_group_normalize_all(group_select_mode='ALL', lock_active=False)
# bind
for m_ in list(body.modifiers):
if m_.type == 'ARMATURE':
body.modifiers.remove(m_)
mod = body.modifiers.new("Armature", 'ARMATURE')
mod.object = arm
body.parent = arm
body.matrix_parent_inverse = arm.matrix_world.inverted()
bpy.data.objects.remove(donor, do_unlink=True)
# verify
tot = np.zeros(n)
for v in me.vertices:
tot[v.index] = sum(g.weight for g in v.groups)
dg = bpy.context.evaluated_depsgraph_get()
evo = body.evaluated_get(dg)
tmp = evo.to_mesh()
Dv = np.empty(len(tmp.vertices) * 3); tmp.vertices.foreach_get("co", Dv); Dv = Dv.reshape(-1, 3)
evo.to_mesh_clear()
drift = np.linalg.norm(Dv - V_ref, axis=1).max() * UNITM * 1000
print("\n=== VERIFY ===")
print(f" weight sums: min {tot.min():.4f} mean {tot.mean():.4f} max {tot.max():.4f}")
print(f" unweighted vertices: {int((tot < 1e-6).sum())}")
print(f" DEFORMED at rest: z {Dv[:,2].min():.5f}..{Dv[:,2].max():.5f} drift {drift:.4f} mm")
assert tot.min() > 0.99 and drift < 0.01, "rig transfer failed verification"
bpy.ops.wm.save_as_mainfile(filepath=OUTB)
bpy.ops.object.select_all(action='DESELECT')
arm.select_set(True); body.select_set(True)
bpy.context.view_layer.objects.active = arm
bpy.ops.export_scene.gltf(filepath=OUTG, export_format='GLB', use_selection=True,
export_image_format='AUTO', export_jpeg_quality=95,
export_yup=True, export_apply=False, export_skins=True)
log(f"EXPORTED {OUTG} ({os.path.getsize(OUTG)/1e6:.2f} MB)")
print("RIG53_DONE")
+259
View File
@@ -0,0 +1,259 @@
# Stage 54 (v02): kill the rusty crotch patch on the v02 atlas — the focused re-run of what
# 36_refill.py did for v01.
#
# blender --background --python 54_crotch_refill.py -- <in.blend> <out.blend>
#
# Why not just re-run 36: it rebuilds its mask from masks.npz + 00_welded.blend, and the welded
# reference didn't survive the prune. It's also not needed here — the v02 body texture comes from
# the 34_v04 master whose garment regions were already patch-filled clean; the ONLY colour
# regression is the donor-crotch rust, and that region is a geometric box (the same box 36 used:
# |x| < 0.075, 0.340 < z < 0.480, in the 0.98-unit body frame).
#
# Method is 36's, unchanged in the ways that mattered:
# - the fill tone is a HARMONIC solve on the mesh with Dirichlet boundaries (CG, not Jacobi) —
# equal to her real skin at the mask edge by construction, and seam-proof across the
# torso/leg chart border the crotch straddles;
# - grain transplanted from clean skin tiles so it is not a decal;
# - normal flattened and rm set to surrounding-skin median over the same texels, which is what
# keeps the region featureless.
# Texture-only: works directly on the RIGGED master, no re-rig needed.
import bpy, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUT = argv[0], argv[1]
GROW = 3
GRAIN_T = 16
FEATHER = 4
t0 = time.time()
def log(m):
print(f"[cr54 {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))
me = ob.data
n_v, n_l, n_f = len(me.vertices), len(me.loops), len(me.polygons)
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
z = co[:, 2] - co[:, 2].min()
log(f"{n_v}v {n_f}f")
# ---- the mask: 36's crotch box, slightly extended down the inner thigh, + grow rings ----
mask = (np.abs(co[:, 0]) < 0.075) & (z > 0.320) & (z < 0.480)
ev = np.empty(len(me.edges) * 2, dtype=np.int32); me.edges.foreach_get("vertices", ev)
ev = ev.reshape(-1, 2)
for _ in range(GROW):
hit = mask[ev[:, 0]] | mask[ev[:, 1]]
mask[ev[hit, 0]] = True
mask[ev[hit, 1]] = True
log(f"crotch mask: {int(mask.sum())} verts")
# ---- images through the material graph ----
src = {}
for slot in ob.material_slots:
mat = slot.material
if not mat or not mat.node_tree:
continue
for node in mat.node_tree.nodes:
if node.type != 'BSDF_PRINCIPLED':
continue
for sock, key in (("Base Color", "base"), ("Normal", "normal"), ("Roughness", "rm")):
if sock not in node.inputs or not node.inputs[sock].links:
continue
nd = node.inputs[sock].links[0].from_node
seen = set()
while nd and nd.type != 'TEX_IMAGE' and id(nd) not in seen:
seen.add(id(nd))
nxt = None
for i in nd.inputs:
if i.links:
nxt = i.links[0].from_node
break
nd = nxt
if nd and nd.type == 'TEX_IMAGE' and nd.image:
src[key] = nd.image
base = src["base"]
W, H = base.size
buf = np.empty(W * H * 4, dtype=np.float32)
base.pixels.foreach_get(buf)
tex = buf.reshape(H, W, 4)
rgb = tex[:, :, :3].astype(np.float64)
log(f"atlas '{base.name}' {W}x{H}")
loops_v = np.empty(n_l, dtype=np.int32); me.loops.foreach_get("vertex_index", loops_v)
uv = np.empty(n_l * 2); me.uv_layers.active.data.foreach_get("uv", uv); uv = uv.reshape(-1, 2)
l_start = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_start", l_start)
l_tot = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_total", l_tot)
li = l_start[l_tot == 3]
px = np.clip(np.round(uv[:, 0] * (W - 1)).astype(np.int32), 0, W - 1)
py = np.clip(np.round(uv[:, 1] * (H - 1)).astype(np.int32), 0, H - 1)
lc = rgb[py, px]
vc = np.zeros((n_v, 3))
for c in range(3):
vc[:, c] = np.bincount(loops_v, weights=lc[:, c], minlength=n_v)
vn = np.maximum(np.bincount(loops_v, minlength=n_v), 1)
vc /= vn[:, None]
# ---- harmonic fill (CG on the graph Laplacian, Dirichlet boundary = her real skin) ----
o_ = np.concatenate([ev[:, 0], ev[:, 1]])
n_ = np.concatenate([ev[:, 1], ev[:, 0]])
deg = np.maximum(np.bincount(o_, minlength=n_v).astype(np.float64), 1.0)
mf = mask.astype(np.float64)
def A_mul(x):
xm = x * mf
return (deg * xm - np.bincount(o_, weights=xm[n_], minlength=n_v)) * mf
fill = vc.copy()
for c in range(3):
known = vc[:, c] * (1.0 - mf)
b = np.bincount(o_, weights=known[n_], minlength=n_v) * mf
x = np.zeros(n_v)
r = b - A_mul(x)
p = r.copy()
rs = float(r @ r); r0 = rs
for it in range(4000):
if rs <= max(r0 * 1e-12, 1e-20):
break
Ap = A_mul(p)
d_ = float(p @ Ap)
if abs(d_) < 1e-30:
break
al = rs / d_
x += al * p; r -= al * Ap
rs2 = float(r @ r)
p = r + (rs2 / rs) * p
rs = rs2
fill[mask, c] = x[mask]
log(f" ch{c}: CG {it+1} iters, residual {np.sqrt(rs/max(r0,1e-30)):.2e}")
bnd = mask & (np.bincount(o_, weights=(1.0 - mf)[n_], minlength=n_v) > 0)
step = np.abs(fill[bnd] - vc[bnd]).max(axis=1)
log(f"boundary agreement: mean {step.mean():.4f} p99 {np.percentile(step,99):.4f}")
# ---- rasterise masked faces ----
IDX = np.stack([li, li + 1, li + 2], axis=1)
V = loops_v[IDX]
face_any = mask[V].any(axis=1)
P = np.stack([uv[IDX][:, :, 0] * (W - 1), uv[IDX][:, :, 1] * (H - 1)], axis=2)
out = rgb.copy()
paint = np.zeros((H, W), dtype=bool)
for f in np.nonzero(face_any)[0]:
p3 = P[f]
x0, x1 = int(p3[:, 0].min()), int(np.ceil(p3[:, 0].max()))
y0, y1 = int(p3[:, 1].min()), int(np.ceil(p3[:, 1].max()))
if x1 < x0 or y1 < y0 or x1 - x0 > 512 or y1 - y0 > 512:
continue
det = ((p3[1, 1] - p3[2, 1]) * (p3[0, 0] - p3[2, 0])
+ (p3[2, 0] - p3[1, 0]) * (p3[0, 1] - p3[2, 1]))
if abs(det) < 1e-12:
continue
gx, gy = np.meshgrid(np.arange(max(x0, 0), min(x1, W - 1) + 1),
np.arange(max(y0, 0), min(y1, H - 1) + 1))
if gx.size == 0:
continue
a = ((p3[1, 1] - p3[2, 1]) * (gx - p3[2, 0]) + (p3[2, 0] - p3[1, 0]) * (gy - p3[2, 1])) / det
b_ = ((p3[2, 1] - p3[0, 1]) * (gx - p3[2, 0]) + (p3[0, 0] - p3[2, 0]) * (gy - p3[2, 1])) / det
c_ = 1.0 - a - b_
ins = (a >= -0.02) & (b_ >= -0.02) & (c_ >= -0.02)
if not ins.any():
continue
aa, bb, cc = a[ins], b_[ins], c_[ins]
w = aa * mf[V[f, 0]] + bb * mf[V[f, 1]] + cc * mf[V[f, 2]]
col = (aa[:, None] * fill[V[f, 0]] + bb[:, None] * fill[V[f, 1]] + cc[:, None] * fill[V[f, 2]])
yy, xx = gy[ins], gx[ins]
hard = w > 0.5
if hard.any():
out[yy[hard], xx[hard]] = col[hard]
paint[yy[hard], xx[hard]] = True
log(f"repainted {int(paint.sum())} texels ({100.0*paint.mean():.3f}%)")
def box(a, r):
def b1(v, ax):
pad = [(0, 0)] * v.ndim
pad[ax] = (r, r)
cs = np.cumsum(np.pad(v, pad, mode="edge"), axis=ax)
return (np.take(cs, np.arange(2 * r, cs.shape[ax]), axis=ax)
- np.take(cs, np.arange(0, cs.shape[ax] - 2 * r), axis=ax)) / (2 * r)
return b1(b1(a, 0), 1)
# grain transplant
grain = np.stack([rgb[:, :, c] - box(rgb[:, :, c], 5) for c in range(3)], axis=2)
tone = np.median(out[paint], axis=0)
cand = []
for ty in range(0, H - GRAIN_T, GRAIN_T):
for tx in range(0, W - GRAIN_T, GRAIN_T):
if paint[ty:ty + GRAIN_T, tx:tx + GRAIN_T].any():
continue
t = rgb[ty:ty + GRAIN_T, tx:tx + GRAIN_T].reshape(-1, 3)
if t.min() < 0.02:
continue
if np.abs(t.mean(axis=0) - tone).max() < 0.10:
cand.append((ty, tx))
rng = np.random.RandomState(11)
if cand:
for ty in range(0, H - GRAIN_T + 1, GRAIN_T):
for tx in range(0, W - GRAIN_T + 1, GRAIN_T):
tm = paint[ty:ty + GRAIN_T, tx:tx + GRAIN_T]
if not tm.any():
continue
sy, sx = cand[rng.randint(len(cand))]
out[ty:ty + GRAIN_T, tx:tx + GRAIN_T][tm] += \
grain[sy:sy + GRAIN_T, sx:sx + GRAIN_T][tm] * 0.85
log(f"grain from {len(cand)} tiles")
# feather rim
a_ = np.ones((H, W))
edge = paint.copy()
for k in range(FEATHER):
grown = edge.copy()
grown[1:-1, 1:-1] |= (edge[:-2, 1:-1] | edge[2:, 1:-1] | edge[1:-1, :-2] | edge[1:-1, 2:])
ring = grown & ~edge
a_[ring] = (k + 1) / (FEATHER + 1.0)
edge = grown
blend = np.where(paint, 1.0, 1.0 - a_)[:, :, None]
final = np.clip(out * blend + rgb * (1 - blend), 0, 1)
b4 = tex.copy()
b4[:, :, :3] = final.astype(np.float32)
base.pixels.foreach_set(b4.reshape(-1))
base.pack()
# normal flat + rm to surrounding median over the same texels
def dil(m, k):
g = m.copy()
for _ in range(k):
n2 = g.copy()
n2[1:, :] |= g[:-1, :]; n2[:-1, :] |= g[1:, :]
n2[:, 1:] |= g[:, :-1]; n2[:, :-1] |= g[:, 1:]
g = n2
return g
soft = dil(paint, 2)
for key in ("normal", "rm"):
if key not in src:
continue
im = src[key]
if tuple(im.size) != (W, H):
continue
a2 = np.empty(W * H * 4, dtype=np.float32)
im.pixels.foreach_get(a2)
arr = a2.reshape(H, W, 4)
if key == "normal":
arr[soft, 0] = 0.5; arr[soft, 1] = 0.5; arr[soft, 2] = 1.0
else:
med = np.median(arr[~dil(paint, 8)][:, :3], axis=0)
arr[soft, 0] = med[0]; arr[soft, 1] = med[1]; arr[soft, 2] = med[2]
im.pixels.foreach_set(arr.reshape(-1))
im.pack()
log(f" {key}: {int(soft.sum())} texels neutralised")
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("CR54_DONE")
+6
View File
@@ -19,3 +19,9 @@ lena_nude_accurig_glb_v01.blend # rigged head: athletic_v04 + AccuRig skeleton
# The athletic_v04_*.jpg beside them are loose copies of the shipped maps. The .blend
# files pack their own textures, so the jpgs are for re-shipping a texture without
# opening Blender — deleting them costs nothing but convenience.
55_fullres_v02.blend # THE archival master (2026-08-12): full-res nude body + the PRISTINE
# ORIGINAL head (52_head_transplant.py, TX_ZCUT=0.80 — 0.775 clips the
# T-pose arms), crotch refill (54) + texture despeckle (40 x2) applied.
# 883,404 v / 1,761,640 f. The only Lena with nothing broken anywhere —
# supersedes 34_v04 as the root (34_v04's head has the shard eyes).
Binary file not shown.