429 lines
19 KiB
Python
429 lines
19 KiB
Python
|
|
# 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: lowest slice that still has vertices on the midline -> that is the crotch
|
||
|
|
HIP_U = 0.45
|
||
|
|
for lv in np.arange(0.60, 0.20, -0.005):
|
||
|
|
m = (u >= lv) & (u < lv + 0.01) & (absn < 0.025)
|
||
|
|
if m.sum() < 3:
|
||
|
|
HIP_U = float(lv + 0.01)
|
||
|
|
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")
|