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