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:
@@ -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")
|
||||
Reference in New Issue
Block a user