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.
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"name": "hunter_skirt_v1",
|
||||
"_worksheet": {
|
||||
"block": "aline_skirt",
|
||||
"waist_w_mm": 498.1,
|
||||
"hem_w_mm": 687.4,
|
||||
"panel_h_mm": 390.0,
|
||||
"tension": 0.91,
|
||||
"flare": 1.38,
|
||||
"lines": {
|
||||
"waist": 0,
|
||||
"side_r": 1,
|
||||
"hem": 2,
|
||||
"side_l": 3
|
||||
}
|
||||
},
|
||||
"md": {
|
||||
"reset": "new_project",
|
||||
"avatar_fbx": "C:/Users/Jeremy/tinqs/animation/tools/tailor/avatar/Lena_QuatSkin_Avatar.fbx",
|
||||
"avatar_scale": 10.0,
|
||||
"add_arrangement_points": true,
|
||||
"auto_translate": true,
|
||||
"zfab": "C:/Users/Public/Documents/MarvelousDesigner/New Assets/Fabric/(Default for Simulation).zfab",
|
||||
"texture": "tools/tailor/textures/hunter_cloth.png",
|
||||
"texture_dpi": 130.0,
|
||||
"panels": [
|
||||
{
|
||||
"name": "front",
|
||||
"dx": 0.0,
|
||||
"note": "aline_skirt block: waist 498.1 (= 0.91 x 1094.7 hip), hem 687.4, H 390.0",
|
||||
"points": [
|
||||
[
|
||||
94.6,
|
||||
390.0
|
||||
],
|
||||
[
|
||||
592.7,
|
||||
390.0
|
||||
],
|
||||
[
|
||||
687.4,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "back",
|
||||
"dx": 937.4,
|
||||
"note": "identical block; lines 0 waist | 1 side_R | 2 hem | 3 side_L",
|
||||
"points": [
|
||||
[
|
||||
94.6,
|
||||
390.0
|
||||
],
|
||||
[
|
||||
592.7,
|
||||
390.0
|
||||
],
|
||||
[
|
||||
687.4,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"seams": [
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 1,
|
||||
"b": "back",
|
||||
"b_line": 1,
|
||||
"reverse_a": true,
|
||||
"reverse_b": true
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 3,
|
||||
"b": "back",
|
||||
"b_line": 3,
|
||||
"reverse_a": true,
|
||||
"reverse_b": true
|
||||
}
|
||||
],
|
||||
"arrangements": [
|
||||
{
|
||||
"panel": "front",
|
||||
"point": "Leg_Skirt_Front",
|
||||
"offset": [
|
||||
50,
|
||||
92,
|
||||
50
|
||||
]
|
||||
},
|
||||
{
|
||||
"panel": "back",
|
||||
"point": "Leg_Skirt_Back",
|
||||
"offset": [
|
||||
0,
|
||||
92,
|
||||
50
|
||||
]
|
||||
}
|
||||
],
|
||||
"sim": {
|
||||
"strengthen": true,
|
||||
"settle_frames": 250,
|
||||
"relax_frames": 50
|
||||
},
|
||||
"cam_viewpoint": 2,
|
||||
"presim_snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v1_presim.png",
|
||||
"snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v1.png",
|
||||
"back_snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v1_back.png",
|
||||
"export_dir": "C:/Users/Jeremy/tinqs/animation/tools/tailor",
|
||||
"export_basename": "lena_hunter_skirt_v1",
|
||||
"exports": []
|
||||
},
|
||||
"expect": {
|
||||
"bands": {
|
||||
"hunter_skirt_v1": {
|
||||
"top_m": 1.075,
|
||||
"bottom_m": 0.685,
|
||||
"tol_m": 0.03,
|
||||
"bottom_tol_m": 0.04,
|
||||
"from": "cover"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
{
|
||||
"name": "hunter_skirt_v2",
|
||||
"_worksheet": {
|
||||
"block": "aline_skirt",
|
||||
"waist_w_mm": 498.1,
|
||||
"hem_w_mm": 722.2,
|
||||
"panel_h_mm": 390.0,
|
||||
"tension": 0.91,
|
||||
"flare": 1.45,
|
||||
"lines": {
|
||||
"waist": 0,
|
||||
"side_r": 1,
|
||||
"hem": 2,
|
||||
"side_l": 3
|
||||
},
|
||||
"elastic_note": "elastic total_length overridden 448.3 -> 329.0 mm per panel. The block's default (0.9 x waist cut) is derived from HIP circumference, which leaves 28 cm of slack at z=1.075 where Lena measures only 71.5 cm -- v1 slid to the crotch and folded inside out. 329 mm = 0.92 x 71.5 cm / 2, i.e. cinched against the body it actually sits on.",
|
||||
"reference": "male-clothing-hunter-gpt-v2{,-side,-back}.png",
|
||||
"measured_targets": {
|
||||
"waist_top_m": 1.075,
|
||||
"solid_hem_m": 0.685,
|
||||
"fray_tips_m": 0.65,
|
||||
"method": "garment band located by warm-dark mask in all 3 reference views, expressed as fraction of figure height (back 0.598/0.373, side 0.610/0.396), averaged and projected onto Lena's 1.777 m",
|
||||
"flare_measured": 1.38,
|
||||
"flare_used": 1.45,
|
||||
"flare_note": "reference flares 1.38 (widths 98->134 px back, 102->142 px front); raised to 1.45 because 1.38 clears Lena's 109.5 cm hip by only 2.7 cm and a binding hem rides up"
|
||||
}
|
||||
},
|
||||
"md": {
|
||||
"reset": "new_project",
|
||||
"avatar_fbx": "C:/Users/Jeremy/tinqs/animation/tools/tailor/avatar/Lena_QuatSkin_Avatar.fbx",
|
||||
"avatar_scale": 10.0,
|
||||
"add_arrangement_points": true,
|
||||
"auto_translate": true,
|
||||
"zfab": "C:/Users/Public/Documents/MarvelousDesigner/New Assets/Fabric/(Default for Simulation).zfab",
|
||||
"texture": "tools/tailor/textures/hunter_cloth.png",
|
||||
"texture_dpi": 130.0,
|
||||
"panels": [
|
||||
{
|
||||
"name": "front",
|
||||
"dx": 0.0,
|
||||
"note": "aline_skirt block: waist 498.1 (= 0.91 x 1094.7 hip), hem 722.2, H 390.0",
|
||||
"points": [
|
||||
[
|
||||
112.1,
|
||||
390.0
|
||||
],
|
||||
[
|
||||
610.2,
|
||||
390.0
|
||||
],
|
||||
[
|
||||
722.2,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "back",
|
||||
"dx": 972.2,
|
||||
"note": "identical block; lines 0 waist | 1 side_R | 2 hem | 3 side_L",
|
||||
"points": [
|
||||
[
|
||||
112.1,
|
||||
390.0
|
||||
],
|
||||
[
|
||||
610.2,
|
||||
390.0
|
||||
],
|
||||
[
|
||||
722.2,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"seams": [
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 1,
|
||||
"b": "back",
|
||||
"b_line": 1,
|
||||
"reverse_a": true,
|
||||
"reverse_b": true
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 3,
|
||||
"b": "back",
|
||||
"b_line": 3,
|
||||
"reverse_a": true,
|
||||
"reverse_b": true
|
||||
}
|
||||
],
|
||||
"arrangements": [
|
||||
{
|
||||
"panel": "front",
|
||||
"point": "Leg_Skirt_Front",
|
||||
"offset": [
|
||||
50,
|
||||
92,
|
||||
50
|
||||
]
|
||||
},
|
||||
{
|
||||
"panel": "back",
|
||||
"point": "Leg_Skirt_Back",
|
||||
"offset": [
|
||||
0,
|
||||
92,
|
||||
50
|
||||
]
|
||||
}
|
||||
],
|
||||
"sim": {
|
||||
"strengthen": true,
|
||||
"settle_frames": 250,
|
||||
"relax_frames": 50,
|
||||
"elastic": [
|
||||
{
|
||||
"panel": "front",
|
||||
"line": 0,
|
||||
"total_length": 329.0
|
||||
},
|
||||
{
|
||||
"panel": "back",
|
||||
"line": 0,
|
||||
"total_length": 329.0
|
||||
}
|
||||
],
|
||||
"elastic_frames": 80
|
||||
},
|
||||
"cam_viewpoint": 2,
|
||||
"presim_snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v2_presim.png",
|
||||
"snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v2.png",
|
||||
"back_snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v2_back.png",
|
||||
"export_dir": "C:/Users/Jeremy/tinqs/animation/tools/tailor",
|
||||
"export_basename": "lena_hunter_skirt_v2",
|
||||
"exports": []
|
||||
},
|
||||
"expect": {
|
||||
"bands": {
|
||||
"hunter_skirt_v2": {
|
||||
"top_m": 1.075,
|
||||
"bottom_m": 0.685,
|
||||
"tol_m": 0.03,
|
||||
"bottom_tol_m": 0.04,
|
||||
"from": "cover"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
{
|
||||
"name": "hunter_skirt_v3",
|
||||
"_worksheet": {
|
||||
"block": "aline_skirt (generated), panel outline then hand-shaped",
|
||||
"reference": "male-clothing-hunter-gpt-v2{,-side,-back}.png",
|
||||
"measured_targets": {
|
||||
"waist_top_m": 1.075,
|
||||
"solid_hem_m": 0.685,
|
||||
"fray_tips_m": 0.65,
|
||||
"method": "garment band located by warm-dark mask in all 3 reference views, expressed as fraction of figure height (back 0.598/0.373, side 0.610/0.396), averaged and projected onto Lena's 1.777 m",
|
||||
"flare_measured": 1.38,
|
||||
"flare_used": 1.45,
|
||||
"flare_note": "reference flares 1.38 (widths 98->134 px back, 102->142 px front); raised to 1.45 because 1.38 clears Lena's 109.5 cm hip by only 2.7 cm and a binding hem rides up"
|
||||
},
|
||||
"why_shaped": "v1 and v2 both failed in SIMULATION while their pre-sim snapshots were clean, which is what proved the pattern and arrangement were right. The straight-sided trapezoid takes its waist from 0.91 x HIP (99.6 cm), but at z=1.075 Lena measures only 71.5 cm -- 28 cm of surplus. v1 (no elastic) slid to the crotch and folded inside out; v2 cinched it with elastic but only at frame 250, by which time the surplus had already buckled into a flap that flipped through itself. Lena's 42 cm hip-to-waist drop over 13 cm of height is the root cause: no straight-sided cone can both grip a 71.5 cm waist and clear a 109.5 cm hip (it would need a 209 cm hem). A curved side seam solves it the way real tailoring does.",
|
||||
"ease_profile_cm": {
|
||||
"waist_z1.075": -3.5,
|
||||
"z1.010": 5.0,
|
||||
"hip_z0.945": 6.1
|
||||
},
|
||||
"hem_circ_cm": 126.0,
|
||||
"silhouette_note": "hem/waist ratio is 1.85 vs the reference's measured 1.38. The extra fullness is forced by Lena's hips, not a drafting choice -- the same garment on the male reference body hangs nearly straight."
|
||||
},
|
||||
"md": {
|
||||
"reset": "new_project",
|
||||
"avatar_fbx": "C:/Users/Jeremy/tinqs/animation/tools/tailor/avatar/Lena_QuatSkin_Avatar.fbx",
|
||||
"avatar_scale": 10.0,
|
||||
"add_arrangement_points": true,
|
||||
"auto_translate": true,
|
||||
"zfab": "C:/Users/Public/Documents/MarvelousDesigner/New Assets/Fabric/(Default for Simulation).zfab",
|
||||
"texture": "tools/tailor/textures/hunter_cloth.png",
|
||||
"texture_dpi": 130.0,
|
||||
"panels": [
|
||||
{
|
||||
"name": "front",
|
||||
"dx": 0.0,
|
||||
"note": "SHAPED skirt panel, not the aline_skirt trapezoid. Curved side seam: waist half 340 (grip, -3.5 cm vs body), out to 478 at y=325 (+5.0 cm), 578 at the hip y=260 (+6.1 cm), 630 at the hem. Lines: 0 waist | 1-3 side_R | 4 hem | 5-7 side_L.",
|
||||
"points": [
|
||||
[
|
||||
145.0,
|
||||
390.0
|
||||
],
|
||||
[
|
||||
485.0,
|
||||
390.0
|
||||
],
|
||||
[
|
||||
554.0,
|
||||
325.0
|
||||
],
|
||||
[
|
||||
604.0,
|
||||
260.0
|
||||
],
|
||||
[
|
||||
630.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
26.0,
|
||||
260.0
|
||||
],
|
||||
[
|
||||
76.0,
|
||||
325.0
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "back",
|
||||
"dx": 880.0,
|
||||
"note": "identical panel offset by dx (NOT mirrored) -> side seams take (True, True)",
|
||||
"points": [
|
||||
[
|
||||
145.0,
|
||||
390.0
|
||||
],
|
||||
[
|
||||
485.0,
|
||||
390.0
|
||||
],
|
||||
[
|
||||
554.0,
|
||||
325.0
|
||||
],
|
||||
[
|
||||
604.0,
|
||||
260.0
|
||||
],
|
||||
[
|
||||
630.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
26.0,
|
||||
260.0
|
||||
],
|
||||
[
|
||||
76.0,
|
||||
325.0
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"seams": [
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 1,
|
||||
"b": "back",
|
||||
"b_line": 1,
|
||||
"reverse_a": true,
|
||||
"reverse_b": true
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 2,
|
||||
"b": "back",
|
||||
"b_line": 2,
|
||||
"reverse_a": true,
|
||||
"reverse_b": true
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 3,
|
||||
"b": "back",
|
||||
"b_line": 3,
|
||||
"reverse_a": true,
|
||||
"reverse_b": true
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 5,
|
||||
"b": "back",
|
||||
"b_line": 5,
|
||||
"reverse_a": true,
|
||||
"reverse_b": true
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 6,
|
||||
"b": "back",
|
||||
"b_line": 6,
|
||||
"reverse_a": true,
|
||||
"reverse_b": true
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 7,
|
||||
"b": "back",
|
||||
"b_line": 7,
|
||||
"reverse_a": true,
|
||||
"reverse_b": true
|
||||
}
|
||||
],
|
||||
"arrangements": [
|
||||
{
|
||||
"panel": "front",
|
||||
"point": "Leg_Skirt_Front",
|
||||
"offset": [
|
||||
50,
|
||||
92,
|
||||
50
|
||||
]
|
||||
},
|
||||
{
|
||||
"panel": "back",
|
||||
"point": "Leg_Skirt_Back",
|
||||
"offset": [
|
||||
0,
|
||||
92,
|
||||
50
|
||||
]
|
||||
}
|
||||
],
|
||||
"sim": {
|
||||
"strengthen": true,
|
||||
"settle_frames": 250,
|
||||
"relax_frames": 50,
|
||||
"elastic": [
|
||||
{
|
||||
"panel": "front",
|
||||
"line": 0,
|
||||
"total_length": 335.0
|
||||
},
|
||||
{
|
||||
"panel": "back",
|
||||
"line": 0,
|
||||
"total_length": 335.0
|
||||
}
|
||||
],
|
||||
"elastic_frames": 80
|
||||
},
|
||||
"cam_viewpoint": 2,
|
||||
"presim_snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v3_presim.png",
|
||||
"snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v3.png",
|
||||
"back_snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v3_back.png",
|
||||
"export_dir": "C:/Users/Jeremy/tinqs/animation/tools/tailor",
|
||||
"export_basename": "lena_hunter_skirt_v3",
|
||||
"exports": []
|
||||
},
|
||||
"expect": {
|
||||
"bands": {
|
||||
"hunter_skirt_v2": {
|
||||
"top_m": 1.075,
|
||||
"bottom_m": 0.685,
|
||||
"tol_m": 0.03,
|
||||
"bottom_tol_m": 0.04,
|
||||
"from": "cover"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
{
|
||||
"name": "hunter_skirt_v5",
|
||||
"_worksheet": {
|
||||
"block": "aline_skirt (generated), panel outline then hand-shaped",
|
||||
"reference": "male-clothing-hunter-gpt-v2{,-side,-back}.png",
|
||||
"measured_targets": {
|
||||
"waist_top_m": 1.075,
|
||||
"solid_hem_m": 0.685,
|
||||
"fray_tips_m": 0.65,
|
||||
"method": "garment band located by warm-dark mask in all 3 reference views, expressed as fraction of figure height (back 0.598/0.373, side 0.610/0.396), averaged and projected onto Lena's 1.777 m",
|
||||
"flare_measured": 1.38,
|
||||
"flare_used": 1.45,
|
||||
"flare_note": "reference flares 1.38 (widths 98->134 px back, 102->142 px front); raised to 1.45 because 1.38 clears Lena's 109.5 cm hip by only 2.7 cm and a binding hem rides up"
|
||||
},
|
||||
"why_shaped": "v1 and v2 both failed in SIMULATION while their pre-sim snapshots were clean, which is what proved the pattern and arrangement were right. The straight-sided trapezoid takes its waist from 0.91 x HIP (99.6 cm), but at z=1.075 Lena measures only 71.5 cm -- 28 cm of surplus. v1 (no elastic) slid to the crotch and folded inside out; v2 cinched it with elastic but only at frame 250, by which time the surplus had already buckled into a flap that flipped through itself. Lena's 42 cm hip-to-waist drop over 13 cm of height is the root cause: no straight-sided cone can both grip a 71.5 cm waist and clear a 109.5 cm hip (it would need a 209 cm hem). A curved side seam solves it the way real tailoring does.",
|
||||
"ease_profile_cm": {
|
||||
"waist_z1.075": -3.5,
|
||||
"z1.010": 5.0,
|
||||
"hip_z0.945": 6.1
|
||||
},
|
||||
"hem_circ_cm": 126.0,
|
||||
"silhouette_note": "hem/waist ratio is 1.85 vs the reference's measured 1.38. The extra fullness is forced by Lena's hips, not a drafting choice -- the same garment on the male reference body hangs nearly straight.",
|
||||
"seam_pairing_discovery": "CROSS-PAIRED sides, front RIGHT <-> back LEFT: [(1,7),(2,6),(3,5),(7,1),(6,2),(5,3)] with (False, False). Established by a 4-cell sweep with the failing case as control (scratchpad/seams/seam_sheet.png): same-index (True,True) -- the rule in the marvelous-designer skill and what blocks.py emits -- COLLAPSES this panel, and so does same-index (False,False). Both cross-paired variants hold. The skill's rule ('identical panels need (True,True) on same-index sides') is derived from a trapezoid whose side is a SINGLE edge; reversal emulates mirroring there. With a 3-segment shaped side seam it does not, and the unrolled-cylinder truth takes over: front spans 0-180 deg and back 180-360, so front's right edge meets back's LEFT.",
|
||||
"failure_history": "v1 no elastic: slid to the crotch, folded inside out. v2 elastic at frame 250: too late, surplus had already buckled. v3 shaped panel but negative waist clearance: solver ejected the penetrating cloth, blew up by frame 20 in BOTH stiff and soft (that symmetry is what ruled out fabric stiffness). v4 clearances fixed but still collapsed -> isolated to seam pairing. Every failure had a CLEAN pre-sim snapshot, which is what kept pointing away from the pattern and at the sim."
|
||||
},
|
||||
"md": {
|
||||
"reset": "new_project",
|
||||
"avatar_fbx": "C:/Users/Jeremy/tinqs/animation/tools/tailor/avatar/Lena_QuatSkin_Avatar.fbx",
|
||||
"avatar_scale": 10.0,
|
||||
"add_arrangement_points": true,
|
||||
"auto_translate": true,
|
||||
"zfab": "C:/Users/Public/Documents/MarvelousDesigner/New Assets/Fabric/(Default for Simulation).zfab",
|
||||
"texture": "tools/tailor/textures/hunter_cloth.png",
|
||||
"texture_dpi": 130.0,
|
||||
"panels": [
|
||||
{
|
||||
"name": "front",
|
||||
"dx": 0.0,
|
||||
"note": "SHAPED skirt panel with POSITIVE clearance everywhere above the hip. Waist half 370 (+2.5 cm ease -- v4's -3.5 cm started the cloth INSIDE the body and MD ejected it), 468 at y=325 (+3.0), 575 at the hip y=260 (+5.5), 625 at the hem. Lines: 0 waist | 1-3 side_R | 4 hem | 5-7 side_L.",
|
||||
"points": [
|
||||
[
|
||||
127.5,
|
||||
390.0
|
||||
],
|
||||
[
|
||||
497.5,
|
||||
390.0
|
||||
],
|
||||
[
|
||||
546.5,
|
||||
325.0
|
||||
],
|
||||
[
|
||||
600.0,
|
||||
260.0
|
||||
],
|
||||
[
|
||||
625.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
25.0,
|
||||
260.0
|
||||
],
|
||||
[
|
||||
78.5,
|
||||
325.0
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "back",
|
||||
"dx": 875.0,
|
||||
"note": "identical panel offset by dx (NOT mirrored) -> sides must CROSS-pair",
|
||||
"points": [
|
||||
[
|
||||
127.5,
|
||||
390.0
|
||||
],
|
||||
[
|
||||
497.5,
|
||||
390.0
|
||||
],
|
||||
[
|
||||
546.5,
|
||||
325.0
|
||||
],
|
||||
[
|
||||
600.0,
|
||||
260.0
|
||||
],
|
||||
[
|
||||
625.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
25.0,
|
||||
260.0
|
||||
],
|
||||
[
|
||||
78.5,
|
||||
325.0
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"seams": [
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 1,
|
||||
"b": "back",
|
||||
"b_line": 7,
|
||||
"reverse_a": false,
|
||||
"reverse_b": false
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 2,
|
||||
"b": "back",
|
||||
"b_line": 6,
|
||||
"reverse_a": false,
|
||||
"reverse_b": false
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 3,
|
||||
"b": "back",
|
||||
"b_line": 5,
|
||||
"reverse_a": false,
|
||||
"reverse_b": false
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 7,
|
||||
"b": "back",
|
||||
"b_line": 1,
|
||||
"reverse_a": false,
|
||||
"reverse_b": false
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 6,
|
||||
"b": "back",
|
||||
"b_line": 2,
|
||||
"reverse_a": false,
|
||||
"reverse_b": false
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 5,
|
||||
"b": "back",
|
||||
"b_line": 3,
|
||||
"reverse_a": false,
|
||||
"reverse_b": false
|
||||
}
|
||||
],
|
||||
"arrangements": [
|
||||
{
|
||||
"panel": "front",
|
||||
"point": "Leg_Skirt_Front",
|
||||
"offset": [
|
||||
50,
|
||||
92,
|
||||
50
|
||||
]
|
||||
},
|
||||
{
|
||||
"panel": "back",
|
||||
"point": "Leg_Skirt_Back",
|
||||
"offset": [
|
||||
0,
|
||||
92,
|
||||
50
|
||||
]
|
||||
}
|
||||
],
|
||||
"sim": {
|
||||
"strengthen": true,
|
||||
"settle_frames": 150,
|
||||
"relax_frames": 50,
|
||||
"elastic": [
|
||||
{
|
||||
"panel": "front",
|
||||
"line": 0,
|
||||
"total_length": 350.0
|
||||
},
|
||||
{
|
||||
"panel": "back",
|
||||
"line": 0,
|
||||
"total_length": 350.0
|
||||
}
|
||||
],
|
||||
"elastic_frames": 60
|
||||
},
|
||||
"cam_viewpoint": 2,
|
||||
"presim_snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v5_presim.png",
|
||||
"snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v5.png",
|
||||
"back_snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v5_back.png",
|
||||
"export_dir": "C:/Users/Jeremy/tinqs/animation/tools/tailor",
|
||||
"export_basename": "lena_hunter_skirt_v5",
|
||||
"exports": []
|
||||
},
|
||||
"expect": {
|
||||
"bands": {
|
||||
"hunter_skirt_v2": {
|
||||
"top_m": 1.075,
|
||||
"bottom_m": 0.685,
|
||||
"tol_m": 0.03,
|
||||
"bottom_tol_m": 0.04,
|
||||
"from": "cover"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
{
|
||||
"name": "hunter_skirt_v8",
|
||||
"_worksheet": {
|
||||
"block": "aline_skirt (generated), panel outline then hand-shaped",
|
||||
"reference": "male-clothing-hunter-gpt-v2{,-side,-back}.png",
|
||||
"measured_targets": {
|
||||
"waist_top_m": 1.075,
|
||||
"solid_hem_m": 0.685,
|
||||
"fray_tips_m": 0.65,
|
||||
"method": "garment band located by warm-dark mask in all 3 reference views, expressed as fraction of figure height (back 0.598/0.373, side 0.610/0.396), averaged and projected onto Lena's 1.777 m",
|
||||
"flare_measured": 1.38,
|
||||
"flare_used": 1.45,
|
||||
"flare_note": "reference flares 1.38 (widths 98->134 px back, 102->142 px front); raised to 1.45 because 1.38 clears Lena's 109.5 cm hip by only 2.7 cm and a binding hem rides up"
|
||||
},
|
||||
"why_shaped": "v1 and v2 both failed in SIMULATION while their pre-sim snapshots were clean, which is what proved the pattern and arrangement were right. The straight-sided trapezoid takes its waist from 0.91 x HIP (99.6 cm), but at z=1.075 Lena measures only 71.5 cm -- 28 cm of surplus. v1 (no elastic) slid to the crotch and folded inside out; v2 cinched it with elastic but only at frame 250, by which time the surplus had already buckled into a flap that flipped through itself. Lena's 42 cm hip-to-waist drop over 13 cm of height is the root cause: no straight-sided cone can both grip a 71.5 cm waist and clear a 109.5 cm hip (it would need a 209 cm hem). A curved side seam solves it the way real tailoring does.",
|
||||
"ease_profile_cm": {
|
||||
"waist_z1.075": -3.5,
|
||||
"z1.010": 5.0,
|
||||
"hip_z0.945": 6.1
|
||||
},
|
||||
"hem_circ_cm": 126.0,
|
||||
"silhouette_note": "hem/waist ratio is 1.85 vs the reference's measured 1.38. The extra fullness is forced by Lena's hips, not a drafting choice -- the same garment on the male reference body hangs nearly straight.",
|
||||
"seam_pairing_discovery": "CROSS-PAIRED sides, front RIGHT <-> back LEFT: [(1,7),(2,6),(3,5),(7,1),(6,2),(5,3)] with (False, False). Established by a 4-cell sweep with the failing case as control (scratchpad/seams/seam_sheet.png): same-index (True,True) -- the rule in the marvelous-designer skill and what blocks.py emits -- COLLAPSES this panel, and so does same-index (False,False). Both cross-paired variants hold. The skill's rule ('identical panels need (True,True) on same-index sides') is derived from a trapezoid whose side is a SINGLE edge; reversal emulates mirroring there. With a 3-segment shaped side seam it does not, and the unrolled-cylinder truth takes over: front spans 0-180 deg and back 180-360, so front's right edge meets back's LEFT.",
|
||||
"failure_history": "v1 no elastic: slid to the crotch, folded inside out. v2 elastic at frame 250: too late, surplus had already buckled. v3 shaped panel but negative waist clearance: solver ejected the penetrating cloth, blew up by frame 20 in BOTH stiff and soft (that symmetry is what ruled out fabric stiffness). v4 clearances fixed but still collapsed -> isolated to seam pairing. Every failure had a CLEAN pre-sim snapshot, which is what kept pointing away from the pattern and at the sim.",
|
||||
"seam_pairing_SOLVED": "CROSS-paired sides with EXACTLY ONE SIDE REVERSED: reverse_a=False, reverse_b=True on [(1,11),(2,10),(3,9),(4,8),(5,7),(11,1),(10,2),(9,3),(8,4),(7,5)]. Two things had to be right and they are independent. (1) WHICH edges pair: the panels are identical, not mirrored, so front spans 0-180 deg and back 180-360 -- front's RIGHT edge meets back's LEFT. Same-index pairing (what blocks.py emits, and what the skill states for identical panels) collapses this garment in BOTH parities. (2) WHICH DIRECTION: front's right segments run downward (waist->hem), back's left segments run upward, so (False,False) sews every segment's top to its partner's bottom and (True,True) flips both and is EQUIVALENT -- that is exactly why sweep cells C and D were indistinguishable. Reversing one side makes waist-end meet waist-end. Verified stable frames 0-260 with no roll-up, twist or inversion.",
|
||||
"strengthen_note": "strengthen=False. On this garment SetPatternStrengthen appears to INFLATE rather than merely stiffen: the strengthened cell balloons, its hem curls into a roll and it loses ~24 cm of length, converging to that shape by frame 40 and holding it. The soft cell reads correctly. The skill's 'strengthen through the whole settle' rule is for garments that must wrap on from a loose arrangement; this one is drafted to fit and materialises already in place, so it does not need it.",
|
||||
"elastic_note": "No elastic. The waist is 72 cm against a 68.8 cm body (+3.2 cm) and 48.5 cm smaller than the widest body point (120.5 cm at the thighs), so it physically cannot slide down -- tension holds it, which is the doctrine's actual intent. Elastic was tried and was not the fix for any failure mode.",
|
||||
"qc_measured": {
|
||||
"command": "python tools/tailor/qc_placement.py <front.png> --json --colors 92,69,54 110,82,64 82,62,48 --tol 20",
|
||||
"mask_note": "The garment renders at b/r ~0.59 and skin at ~0.42, which is what separates them; a single colour at tol 46 catches skin and reports phantom bands up at the shoulders.",
|
||||
"band_top_m": 1.062,
|
||||
"band_bottom_m": 0.669,
|
||||
"band_span_m": 0.393,
|
||||
"band_px": 12435,
|
||||
"verdict": "PASS. top -13 mm vs target 1.075 (tol +/-30), hem -16 mm vs 0.685 (tol +/-40). Measured span 0.393 m against a 0.390 m drafted panel, so the cloth is hanging at full length, not gathered."
|
||||
},
|
||||
"not_yet_built": [
|
||||
"Frayed/ragged hem: the reference hem is torn, ~3.5 cm of teeth below the solid hem at 0.685. Must be GEOMETRY (outline teeth, piupiu technique) because the clothing pipeline carries no alpha channel.",
|
||||
"Diagonal wrap overlap across the front-left of the reference.",
|
||||
"Back waist tie: knot plus two short hanging ends."
|
||||
]
|
||||
},
|
||||
"md": {
|
||||
"reset": "new_project",
|
||||
"avatar_fbx": "C:/Users/Jeremy/tinqs/animation/tools/tailor/avatar/Lena_QuatSkin_Avatar.fbx",
|
||||
"avatar_scale": 10.0,
|
||||
"add_arrangement_points": true,
|
||||
"auto_translate": true,
|
||||
"zfab": "C:/Users/Public/Documents/MarvelousDesigner/New Assets/Fabric/(Default for Simulation).zfab",
|
||||
"texture": "tools/tailor/textures/hunter_cloth.png",
|
||||
"texture_dpi": 130.0,
|
||||
"panels": [
|
||||
{
|
||||
"name": "front",
|
||||
"dx": 0.0,
|
||||
"note": "Width profile drafted against the MEASURED BODY HULL (both legs), not the measurement card: the card's body_circ_at CLAMPS to hip_circ below the hip, hiding that Lena's two thighs together are 120.5 cm at z=0.82 vs 105.6 cm at the hip. Levels (y, half-mm): 390/360, 325/465, 260.5/560, 215/615, 135/645, 0/665. >=3.2 cm ease everywhere. Lines: 0 waist | 1-5 side_R (top->bottom) | 6 hem | 7-11 side_L (bottom->top).",
|
||||
"points": [
|
||||
[
|
||||
152.5,
|
||||
390.0
|
||||
],
|
||||
[
|
||||
512.5,
|
||||
390.0
|
||||
],
|
||||
[
|
||||
565.0,
|
||||
325.0
|
||||
],
|
||||
[
|
||||
612.5,
|
||||
260.5
|
||||
],
|
||||
[
|
||||
640.0,
|
||||
215.0
|
||||
],
|
||||
[
|
||||
655.0,
|
||||
135.0
|
||||
],
|
||||
[
|
||||
665.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
10.0,
|
||||
135.0
|
||||
],
|
||||
[
|
||||
25.0,
|
||||
215.0
|
||||
],
|
||||
[
|
||||
52.5,
|
||||
260.5
|
||||
],
|
||||
[
|
||||
100.0,
|
||||
325.0
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "back",
|
||||
"dx": 915.0,
|
||||
"note": "identical panel offset by dx (NOT mirrored) -> sides CROSS-pair, ONE reversed",
|
||||
"points": [
|
||||
[
|
||||
152.5,
|
||||
390.0
|
||||
],
|
||||
[
|
||||
512.5,
|
||||
390.0
|
||||
],
|
||||
[
|
||||
565.0,
|
||||
325.0
|
||||
],
|
||||
[
|
||||
612.5,
|
||||
260.5
|
||||
],
|
||||
[
|
||||
640.0,
|
||||
215.0
|
||||
],
|
||||
[
|
||||
655.0,
|
||||
135.0
|
||||
],
|
||||
[
|
||||
665.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
10.0,
|
||||
135.0
|
||||
],
|
||||
[
|
||||
25.0,
|
||||
215.0
|
||||
],
|
||||
[
|
||||
52.5,
|
||||
260.5
|
||||
],
|
||||
[
|
||||
100.0,
|
||||
325.0
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"seams": [
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 1,
|
||||
"b": "back",
|
||||
"b_line": 11,
|
||||
"reverse_a": false,
|
||||
"reverse_b": true
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 2,
|
||||
"b": "back",
|
||||
"b_line": 10,
|
||||
"reverse_a": false,
|
||||
"reverse_b": true
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 3,
|
||||
"b": "back",
|
||||
"b_line": 9,
|
||||
"reverse_a": false,
|
||||
"reverse_b": true
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 4,
|
||||
"b": "back",
|
||||
"b_line": 8,
|
||||
"reverse_a": false,
|
||||
"reverse_b": true
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 5,
|
||||
"b": "back",
|
||||
"b_line": 7,
|
||||
"reverse_a": false,
|
||||
"reverse_b": true
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 11,
|
||||
"b": "back",
|
||||
"b_line": 1,
|
||||
"reverse_a": false,
|
||||
"reverse_b": true
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 10,
|
||||
"b": "back",
|
||||
"b_line": 2,
|
||||
"reverse_a": false,
|
||||
"reverse_b": true
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 9,
|
||||
"b": "back",
|
||||
"b_line": 3,
|
||||
"reverse_a": false,
|
||||
"reverse_b": true
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 8,
|
||||
"b": "back",
|
||||
"b_line": 4,
|
||||
"reverse_a": false,
|
||||
"reverse_b": true
|
||||
},
|
||||
{
|
||||
"a": "front",
|
||||
"a_line": 7,
|
||||
"b": "back",
|
||||
"b_line": 5,
|
||||
"reverse_a": false,
|
||||
"reverse_b": true
|
||||
}
|
||||
],
|
||||
"arrangements": [
|
||||
{
|
||||
"panel": "front",
|
||||
"point": "Leg_Skirt_Front",
|
||||
"offset": [
|
||||
50,
|
||||
92,
|
||||
50
|
||||
]
|
||||
},
|
||||
{
|
||||
"panel": "back",
|
||||
"point": "Leg_Skirt_Back",
|
||||
"offset": [
|
||||
0,
|
||||
92,
|
||||
50
|
||||
]
|
||||
}
|
||||
],
|
||||
"sim": {
|
||||
"strengthen": false,
|
||||
"settle_frames": 200,
|
||||
"relax_frames": 60
|
||||
},
|
||||
"cam_viewpoint": 2,
|
||||
"presim_snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v8_presim.png",
|
||||
"snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v8.png",
|
||||
"back_snapshot": "C:/Users/Jeremy/AppData/Local/Temp/tinqs_md_hunter_skirt_v8_back.png",
|
||||
"export_dir": "C:/Users/Jeremy/tinqs/animation/tools/tailor",
|
||||
"export_basename": "lena_hunter_skirt_v8",
|
||||
"exports": [
|
||||
"zprj",
|
||||
"fbx",
|
||||
"obj",
|
||||
"zpac"
|
||||
]
|
||||
},
|
||||
"expect": {
|
||||
"bands": {
|
||||
"hunter_skirt_v8": {
|
||||
"top_m": 1.075,
|
||||
"bottom_m": 0.685,
|
||||
"tol_m": 0.03,
|
||||
"bottom_tol_m": 0.04,
|
||||
"from": "cover"
|
||||
}
|
||||
}
|
||||
},
|
||||
"source": "C:/Users/Jeremy/tinqs/animation/tools/tailor/lena_hunter_skirt_v8_garment.fbx",
|
||||
"body": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb",
|
||||
"weld_threshold": 0.0006,
|
||||
"shell_mm": 4,
|
||||
"min_island_verts": 40,
|
||||
"align": {
|
||||
"top_bone": "spine_01",
|
||||
"scale_xy": 0.1,
|
||||
"scale_z": 0.1,
|
||||
"z_nudge": 0.0,
|
||||
"xy_nudge": [
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"parts": {
|
||||
"HunterSkirt": {
|
||||
"slot": "Legs",
|
||||
"islands": [
|
||||
0
|
||||
],
|
||||
"tris": 4000,
|
||||
"planar_deg": 5,
|
||||
"fit": {
|
||||
"target": "BODY_SHELL",
|
||||
"mask": "full",
|
||||
"offset_mm": 3,
|
||||
"wrap_mode": "OUTSIDE",
|
||||
"hem_mm": 14
|
||||
},
|
||||
"weights": "dress",
|
||||
"color": [
|
||||
0.42,
|
||||
0.31,
|
||||
0.23
|
||||
],
|
||||
"texture": "../tools/tailor/textures/hunter_cloth.png"
|
||||
}
|
||||
},
|
||||
"export": {
|
||||
"out_dir": "C:/Users/Jeremy/tinqs/ariki-game/assets/quaternius/outfits/hunter",
|
||||
"gender": "Female",
|
||||
"set": "Hunter",
|
||||
"note": "Hunter wrap skirt (brown coarse cloth) - Legs slot"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
# graft_hands.py — transplant a working 40-bone hand rig onto a body that was rigged without one.
|
||||
#
|
||||
# blender --background --python tools/graft_hands.py -- \
|
||||
# --target <rigged_body.glb> --donor <Ariki_Female_QuatSkin.glb> --out <out.glb>
|
||||
# blender --background --python tools/graft_hands.py -- --selftest --donor <...QuatSkin.glb>
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# The rig-graft lane (.agents/plans/rig-graft-lane-2026-08-04.md) cuts the hands off the AccuRig
|
||||
# bait, because close-packed fingers are where every historical hand-mangling came from. That
|
||||
# leaves the body rigged and the hands unrigged — but the hands do not need solving at all:
|
||||
# * the game skeleton's hand chains are FIXED (40 of its 65 joints; verify_body_variant.py
|
||||
# gates on "65 joints in identical order"), so there is nothing to discover, only to place;
|
||||
# * the nude lane never touched the hands — measured 0.010 mm mean displacement from the Tripo
|
||||
# original, p50 exactly 0.000 — so a donor's hand weights fit this mesh as-is;
|
||||
# * the shipped clips articulate fingers up to 89 deg relative to each other, so a mitten
|
||||
# (fingers weighted as one mass) would visibly flatten four of the six dances.
|
||||
# So: take the hands from a body that already ships with working ones, aligned at the wrist.
|
||||
#
|
||||
# WHY glTF JSON AND NOT BLENDER
|
||||
# Blender's armature import/export re-derives bone rest orientation from edit-bone head/tail,
|
||||
# which silently rotates rest poses. That is precisely the failure ariki-game/tools/rig_pose_gate.py
|
||||
# was written to catch (Godot animation tracks store ABSOLUTE local transforms, so a rewritten
|
||||
# rest orientation diverges under a clip while a rest-pose comparison still looks fine). Editing
|
||||
# the node graph directly cannot introduce it. bpy is used only for its KD-tree.
|
||||
#
|
||||
# WHAT IT DOES
|
||||
# 1. Reads the canonical joint ORDER and the hand subtree from the donor.
|
||||
# 2. Builds a similarity transform M mapping donor space to target space such that the donor's
|
||||
# wrist frame lands exactly on the target's wrist frame, with bone lengths scaled by the
|
||||
# ratio of forearm lengths (the local scale that matters at the wrist, not global height).
|
||||
# 3. Re-parents the transformed hand chain under the target's lowerarm, baking the scale into
|
||||
# translations so every joint keeps unit scale.
|
||||
# 4. Copies hand skin weights donor -> target by nearest surface, remapped by joint NAME.
|
||||
# 5. Rebuilds skin.joints in the donor's canonical order and recomputes inverse-bind matrices.
|
||||
#
|
||||
# The IBM convention is not assumed: it is recovered from the target's own body joints and
|
||||
# asserted before anything is written (see check_ibm_convention).
|
||||
import json, struct, sys, os, math, argparse
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
from mathutils.kdtree import KDTree
|
||||
except ImportError:
|
||||
KDTree = None
|
||||
|
||||
DT = {5120: np.int8, 5121: np.uint8, 5122: np.int16, 5123: np.uint16,
|
||||
5125: np.uint32, 5126: np.float32}
|
||||
CT = {v: k for k, v in DT.items()}
|
||||
NC = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}
|
||||
HAND_ROOTS = ("hand_l", "hand_r")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- glTF container
|
||||
class Gltf:
|
||||
def __init__(self, path):
|
||||
data = open(path, "rb").read()
|
||||
total = struct.unpack("<I", data[8:12])[0]
|
||||
off, self.js, self.bin = 12, None, b""
|
||||
while off < total:
|
||||
ln, ty = struct.unpack("<II", data[off:off + 8]); off += 8
|
||||
ch = data[off:off + ln]; off += ln
|
||||
if ty == 0x4E4F534A: self.js = json.loads(ch)
|
||||
elif ty == 0x004E4942: self.bin = ch
|
||||
self.path = path
|
||||
self.extra = bytearray() # appended payload for new accessors
|
||||
|
||||
# ---- reading
|
||||
def read(self, i):
|
||||
a = self.js["accessors"][i]
|
||||
dt = np.dtype(DT[a["componentType"]]); nc = NC[a["type"]]; n = a["count"]
|
||||
if "bufferView" not in a:
|
||||
return np.zeros((n, nc), dtype=dt)
|
||||
bv = self.js["bufferViews"][a["bufferView"]]
|
||||
off = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
|
||||
stride = bv.get("byteStride") or nc * dt.itemsize
|
||||
if stride == nc * dt.itemsize:
|
||||
return np.frombuffer(self.bin, dtype=dt, count=n * nc, offset=off).reshape(n, nc)
|
||||
raw = np.frombuffer(self.bin, dtype=np.uint8, count=stride * n, offset=off).reshape(n, stride)
|
||||
return raw[:, :nc * dt.itemsize].copy().view(dt).reshape(n, nc)
|
||||
|
||||
# ---- writing (append-only: existing views are never disturbed)
|
||||
def add(self, arr, type_):
|
||||
arr = np.ascontiguousarray(arr)
|
||||
base = len(self.bin) + len(self.extra)
|
||||
pad = (-base) % 4
|
||||
self.extra += b"\x00" * pad
|
||||
off = base + pad
|
||||
raw = arr.tobytes()
|
||||
self.extra += raw
|
||||
self.js["bufferViews"].append({"buffer": 0, "byteOffset": off, "byteLength": len(raw)})
|
||||
acc = {"bufferView": len(self.js["bufferViews"]) - 1,
|
||||
"componentType": CT[arr.dtype.type], "count": len(arr), "type": type_}
|
||||
if type_ == "VEC3":
|
||||
acc["min"] = [float(x) for x in arr.min(axis=0)]
|
||||
acc["max"] = [float(x) for x in arr.max(axis=0)]
|
||||
self.js["accessors"].append(acc)
|
||||
return len(self.js["accessors"]) - 1
|
||||
|
||||
def save(self, out):
|
||||
blob = bytes(self.bin) + bytes(self.extra)
|
||||
blob += b"\x00" * ((-len(blob)) % 4)
|
||||
self.js["buffers"] = [{"byteLength": len(blob)}]
|
||||
js = json.dumps(self.js, separators=(",", ":")).encode("utf-8")
|
||||
js += b" " * ((-len(js)) % 4)
|
||||
hdr = struct.pack("<III", 0x46546C67, 2, 12 + 8 + len(js) + 8 + len(blob))
|
||||
with open(out, "wb") as f:
|
||||
f.write(hdr)
|
||||
f.write(struct.pack("<II", len(js), 0x4E4F534A)); f.write(js)
|
||||
f.write(struct.pack("<II", len(blob), 0x004E4942)); f.write(blob)
|
||||
|
||||
# ---- topology helpers
|
||||
def parents(self):
|
||||
p = {}
|
||||
for i, n in enumerate(self.js["nodes"]):
|
||||
for c in n.get("children", []): p[c] = i
|
||||
return p
|
||||
|
||||
def by_name(self):
|
||||
return {n.get("name"): i for i, n in enumerate(self.js["nodes"]) if n.get("name")}
|
||||
|
||||
def local(self, i):
|
||||
n = self.js["nodes"][i]
|
||||
if "matrix" in n:
|
||||
return np.array(n["matrix"], dtype=np.float64).reshape(4, 4).T
|
||||
T = np.eye(4); R = np.eye(4); S = np.eye(4)
|
||||
T[:3, 3] = n.get("translation", [0, 0, 0])
|
||||
R[:3, :3] = quat_mat(n.get("rotation", [0, 0, 0, 1]))
|
||||
S[:3, :3] = np.diag(n.get("scale", [1, 1, 1]))
|
||||
return T @ R @ S
|
||||
|
||||
def world(self, i, par=None):
|
||||
par = par if par is not None else self.parents()
|
||||
M = np.eye(4); j = i
|
||||
chain = []
|
||||
while j is not None:
|
||||
chain.append(j); j = par.get(j)
|
||||
for j in reversed(chain): M = M @ self.local(j)
|
||||
return M
|
||||
|
||||
def body_prim(self):
|
||||
best = None
|
||||
for mi, m in enumerate(self.js["meshes"]):
|
||||
for pi, pr in enumerate(m["primitives"]):
|
||||
n = self.js["accessors"][pr["attributes"]["POSITION"]]["count"]
|
||||
if best is None or n > best[0]: best = (n, mi, pi)
|
||||
return best[1], best[2]
|
||||
|
||||
def skinned_node(self):
|
||||
for i, n in enumerate(self.js["nodes"]):
|
||||
if "skin" in n and "mesh" in n: return i
|
||||
return None
|
||||
|
||||
|
||||
def prune_nodes(g, drop):
|
||||
"""Delete nodes and remap every index that referred to them. Leaving them orphaned but
|
||||
present is not good enough: verify_body_variant.py compares the node-NAME SET against the
|
||||
canonical body, and stray nodes fail it (they would also ship as dead scene content)."""
|
||||
drop = set(drop)
|
||||
keep = [i for i in range(len(g.js["nodes"])) if i not in drop]
|
||||
remap = {old: new for new, old in enumerate(keep)}
|
||||
g.js["nodes"] = [g.js["nodes"][i] for i in keep]
|
||||
for n in g.js["nodes"]:
|
||||
if "children" in n:
|
||||
kids = [remap[c] for c in n["children"] if c in remap]
|
||||
if kids: n["children"] = kids
|
||||
else: n.pop("children")
|
||||
for sc in g.js.get("scenes", []):
|
||||
if "nodes" in sc:
|
||||
sc["nodes"] = [remap[i] for i in sc["nodes"] if i in remap]
|
||||
for sk in g.js.get("skins", []):
|
||||
sk["joints"] = [remap[i] for i in sk["joints"] if i in remap]
|
||||
if "skeleton" in sk:
|
||||
if sk["skeleton"] in remap: sk["skeleton"] = remap[sk["skeleton"]]
|
||||
else: sk.pop("skeleton")
|
||||
for an in g.js.get("animations", []):
|
||||
for ch in an.get("channels", []):
|
||||
t = ch.get("target", {})
|
||||
if "node" in t:
|
||||
if t["node"] in remap: t["node"] = remap[t["node"]]
|
||||
else: ch["_orphan"] = True
|
||||
an["channels"] = [c for c in an.get("channels", []) if not c.pop("_orphan", False)]
|
||||
return remap
|
||||
|
||||
|
||||
def quat_mat(q):
|
||||
x, y, z, w = q
|
||||
return np.array([
|
||||
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
|
||||
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
|
||||
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]], dtype=np.float64)
|
||||
|
||||
|
||||
def mat_quat(R):
|
||||
"""Rotation matrix -> xyzw quaternion, via the numerically stable branch."""
|
||||
t = R[0, 0] + R[1, 1] + R[2, 2]
|
||||
if t > 0:
|
||||
s = math.sqrt(t + 1.0) * 2
|
||||
w = 0.25 * s
|
||||
x = (R[2, 1] - R[1, 2]) / s; y = (R[0, 2] - R[2, 0]) / s; z = (R[1, 0] - R[0, 1]) / s
|
||||
elif R[0, 0] > R[1, 1] and R[0, 0] > R[2, 2]:
|
||||
s = math.sqrt(1.0 + R[0, 0] - R[1, 1] - R[2, 2]) * 2
|
||||
w = (R[2, 1] - R[1, 2]) / s; x = 0.25 * s
|
||||
y = (R[0, 1] + R[1, 0]) / s; z = (R[0, 2] + R[2, 0]) / s
|
||||
elif R[1, 1] > R[2, 2]:
|
||||
s = math.sqrt(1.0 + R[1, 1] - R[0, 0] - R[2, 2]) * 2
|
||||
w = (R[0, 2] - R[2, 0]) / s; x = (R[0, 1] + R[1, 0]) / s
|
||||
y = 0.25 * s; z = (R[1, 2] + R[2, 1]) / s
|
||||
else:
|
||||
s = math.sqrt(1.0 + R[2, 2] - R[0, 0] - R[1, 1]) * 2
|
||||
w = (R[1, 0] - R[0, 1]) / s; x = (R[0, 2] + R[2, 0]) / s
|
||||
y = (R[1, 2] + R[2, 1]) / s; z = 0.25 * s
|
||||
q = np.array([x, y, z, w]); return q / np.linalg.norm(q)
|
||||
|
||||
|
||||
def decompose_unit(M):
|
||||
"""(translation, xyzw quaternion) with scale stripped — joints stay unit-scale so a scale
|
||||
factor never propagates down the finger chain."""
|
||||
R = M[:3, :3].copy()
|
||||
for k in range(3):
|
||||
n = np.linalg.norm(R[:, k])
|
||||
if n > 0: R[:, k] /= n
|
||||
return M[:3, 3].copy(), mat_quat(R)
|
||||
|
||||
|
||||
def subtree(g, root, par=None):
|
||||
out, stack = [], [root]
|
||||
while stack:
|
||||
i = stack.pop(0); out.append(i)
|
||||
stack += g.js["nodes"][i].get("children", [])
|
||||
return out
|
||||
|
||||
|
||||
def check_ibm_convention(g, tol=1e-4):
|
||||
"""Recover, rather than assume, how this file relates inverse-bind matrices to rest poses.
|
||||
Returns the mesh-node world matrix that makes IBM == inv(world(joint)) @ Wmesh hold."""
|
||||
sk = g.js["skins"][0]
|
||||
if "inverseBindMatrices" not in sk: return np.eye(4), 0.0
|
||||
ibm = g.read(sk["inverseBindMatrices"]).reshape(-1, 4, 4).transpose(0, 2, 1)
|
||||
par = g.parents()
|
||||
node = g.skinned_node()
|
||||
Wmesh = g.world(node, par) if node is not None else np.eye(4)
|
||||
worst = 0.0
|
||||
for k, j in enumerate(sk["joints"]):
|
||||
pred = np.linalg.inv(g.world(j, par)) @ Wmesh
|
||||
worst = max(worst, float(np.abs(pred - ibm[k]).max()))
|
||||
return Wmesh, worst
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- the graft
|
||||
def unit_scale(M):
|
||||
"""Same matrix with each basis vector normalised — scale removed, rotation kept."""
|
||||
out = M.copy()
|
||||
for k in range(3):
|
||||
n = np.linalg.norm(out[:3, k])
|
||||
if n > 0: out[:3, k] /= n
|
||||
return out
|
||||
|
||||
|
||||
def wrist_transform(gt, gd, side, tn, dn, par_t, par_d):
|
||||
"""Similarity transform mapping DONOR world space to TARGET world space so the donor wrist
|
||||
frame lands on the target wrist frame. Scale comes from forearm length — the length that
|
||||
governs how far the fingers reach; global body height would be wrong for a differently
|
||||
proportioned arm.
|
||||
|
||||
Both wrist frames are stripped of their own scale before composing. Without that, a target
|
||||
whose nodes already carry a scale gets it applied TWICE (once inside Wt, once via the forearm
|
||||
ratio, which was measured in that same scaled space) — selftest B caught exactly this, as a
|
||||
0.055 u placement error that grew toward the finger tips."""
|
||||
hand, fore = f"hand_{side}", f"lowerarm_{side}"
|
||||
if hand not in tn:
|
||||
raise SystemExit(f"target has no '{hand}' node — cannot align. AccuRig must return at "
|
||||
f"least a wrist joint, or use --wrist-from-forearm (not implemented).")
|
||||
Wt = gt.world(tn[hand], par_t)
|
||||
Wd = gd.world(dn[hand], par_d)
|
||||
s = 1.0
|
||||
if fore in tn and fore in dn:
|
||||
lt = np.linalg.norm(Wt[:3, 3] - gt.world(tn[fore], par_t)[:3, 3])
|
||||
ld = np.linalg.norm(Wd[:3, 3] - gd.world(dn[fore], par_d)[:3, 3])
|
||||
if ld > 1e-9: s = lt / ld
|
||||
Sc = np.eye(4); Sc[:3, :3] *= s
|
||||
return unit_scale(Wt) @ Sc @ np.linalg.inv(unit_scale(Wd)), s
|
||||
|
||||
|
||||
def graft(target, donor, out, hand_frac=0.756, verbose=True):
|
||||
gt, gd = Gltf(target), Gltf(donor)
|
||||
tn, dn = gt.by_name(), gd.by_name()
|
||||
par_t, par_d = gt.parents(), gd.parents()
|
||||
|
||||
Wmesh_t, err_t = check_ibm_convention(gt)
|
||||
_, err_d = check_ibm_convention(gd)
|
||||
if verbose:
|
||||
print(f"[ibm] convention residual target {err_t:.2e} donor {err_d:.2e}")
|
||||
if err_d > 1e-3:
|
||||
raise SystemExit(f"donor IBMs do not follow inv(world(joint)) @ Wmesh (residual "
|
||||
f"{err_d:.2e}); refusing to guess a different convention")
|
||||
|
||||
canonical = [gd.js["nodes"][j].get("name") for j in gd.js["skins"][0]["joints"]]
|
||||
skin_t = gt.js["skins"][0]
|
||||
tj_names = [gt.js["nodes"][j].get("name") for j in skin_t["joints"]]
|
||||
|
||||
# ---- 1. copy each donor hand subtree into the target, transformed to the target wrist
|
||||
new_nodes = {}
|
||||
dropped = []
|
||||
for side in ("l", "r"):
|
||||
M, s = wrist_transform(gt, gd, side, tn, dn, par_t, par_d)
|
||||
chain = subtree(gd, dn[f"hand_{side}"])
|
||||
if verbose:
|
||||
print(f"[wrist] {side}: forearm-length scale x{s:.5f}, {len(chain)} joints")
|
||||
# drop any hand chain the target already has, so this is a replacement not a duplicate
|
||||
if f"hand_{side}" in tn:
|
||||
old = subtree(gt, tn[f"hand_{side}"], par_t)
|
||||
p = par_t.get(old[0])
|
||||
if p is not None:
|
||||
gt.js["nodes"][p]["children"] = [c for c in gt.js["nodes"][p].get("children", [])
|
||||
if c != old[0]]
|
||||
dropped.extend(old) # removed for real at the end, once indices settle
|
||||
for j in chain:
|
||||
Wnew = M @ gd.world(j, par_d)
|
||||
t, q = decompose_unit(Wnew)
|
||||
gt.js["nodes"].append({"name": gd.js["nodes"][j].get("name"),
|
||||
"translation": [float(x) for x in t],
|
||||
"rotation": [float(x) for x in q]})
|
||||
new_nodes[gd.js["nodes"][j].get("name")] = len(gt.js["nodes"]) - 1
|
||||
# re-parent: the chain root goes under the target's forearm, children under their own
|
||||
for j in chain:
|
||||
nm = gd.js["nodes"][j].get("name")
|
||||
kids = [gd.js["nodes"][c].get("name") for c in gd.js["nodes"][j].get("children", [])]
|
||||
if kids:
|
||||
gt.js["nodes"][new_nodes[nm]]["children"] = [new_nodes[k] for k in kids]
|
||||
root_nm = gd.js["nodes"][dn[f'hand_{side}']].get("name")
|
||||
fore_i = tn.get(f"lowerarm_{side}")
|
||||
if fore_i is None:
|
||||
raise SystemExit(f"target has no lowerarm_{side} to parent the hand under")
|
||||
gt.js["nodes"][fore_i].setdefault("children", []).append(new_nodes[root_nm])
|
||||
# local transforms are currently WORLD; convert to parent-relative
|
||||
par_t = gt.parents()
|
||||
for j in chain:
|
||||
nm = gd.js["nodes"][j].get("name"); i = new_nodes[nm]
|
||||
Wnew = M @ gd.world(j, par_d)
|
||||
p = par_t.get(i)
|
||||
Lp = np.linalg.inv(gt.world(p, par_t)) @ Wnew if p is not None else Wnew
|
||||
t, q = decompose_unit(Lp)
|
||||
gt.js["nodes"][i]["translation"] = [float(x) for x in t]
|
||||
gt.js["nodes"][i]["rotation"] = [float(x) for x in q]
|
||||
par_t = gt.parents()
|
||||
|
||||
# ---- 2. rebuild skin.joints in the donor's canonical order
|
||||
tn = gt.by_name(); par_t = gt.parents()
|
||||
joints_new, missing = [], []
|
||||
for nm in canonical:
|
||||
if nm in new_nodes: joints_new.append(new_nodes[nm])
|
||||
elif nm in tn: joints_new.append(tn[nm])
|
||||
else: missing.append(nm)
|
||||
if missing:
|
||||
raise SystemExit(f"target is missing non-hand joints the donor defines: {missing[:6]}")
|
||||
old_index = {nm: k for k, nm in enumerate(tj_names)}
|
||||
new_index = {nm: k for k, nm in enumerate(canonical)}
|
||||
|
||||
# ---- 3. weights: donor hand -> target hand vertices, by nearest surface, remapped by NAME
|
||||
mi_t, pi_t = gt.body_prim(); prim_t = gt.js["meshes"][mi_t]["primitives"][pi_t]
|
||||
mi_d, pi_d = gd.body_prim(); prim_d = gd.js["meshes"][mi_d]["primitives"][pi_d]
|
||||
Pt = np.array(gt.read(prim_t["attributes"]["POSITION"]), dtype=np.float64)
|
||||
Pd = np.array(gd.read(prim_d["attributes"]["POSITION"]), dtype=np.float64)
|
||||
Jd = np.array(gd.read(prim_d["attributes"]["JOINTS_0"]), dtype=np.int64)
|
||||
Wd_ = np.array(gd.read(prim_d["attributes"]["WEIGHTS_0"]), dtype=np.float64)
|
||||
Jt = np.array(gt.read(prim_t["attributes"]["JOINTS_0"]), dtype=np.int64)
|
||||
Wt_ = np.array(gt.read(prim_t["attributes"]["WEIGHTS_0"]), dtype=np.float64)
|
||||
|
||||
dj_names = [gd.js["nodes"][j].get("name") for j in gd.js["skins"][0]["joints"]]
|
||||
hand_joint_ids_d = {k for k, nm in enumerate(dj_names)
|
||||
if nm and (nm.startswith(("index", "middle", "ring", "pinky", "thumb"))
|
||||
or nm in HAND_ROOTS)}
|
||||
# donor vertices that are actually skinned to the hand — the geometric definition of "hand"
|
||||
is_hand_d = np.array([any(Jd[i, k] in hand_joint_ids_d and Wd_[i, k] > 0 for k in range(4))
|
||||
for i in range(len(Pd))])
|
||||
# target hand region by the same bbox-relative cut rigbait_decimate.py uses
|
||||
half = np.abs(Pt[:, 0]).max()
|
||||
is_hand_t = np.abs(Pt[:, 0]) > hand_frac * half
|
||||
|
||||
# Transform donor hand verts into TARGET MESH-LOCAL space, which is the space POSITION data
|
||||
# lives in. M works in world space, so the round trip is:
|
||||
# donor local -> donor world (Wmesh_d) -> target world (M) -> target local (inv Wmesh_t).
|
||||
# Skipping the mesh-node matrices only works when both are identity; selftest B caught that
|
||||
# as a 0.70 u mean nearest-donor distance where it should have been ~0.
|
||||
Wmesh_d, _ = check_ibm_convention(gd)
|
||||
inv_Wmesh_t = np.linalg.inv(Wmesh_t)
|
||||
Md = {}
|
||||
for side in ("l", "r"):
|
||||
Md[side], _ = wrist_transform(gt, gd, side, gt.by_name(), dn, gt.parents(), par_d)
|
||||
def donor_side(i):
|
||||
"""Which hand a donor vertex belongs to, from the joint it is actually weighted to —
|
||||
not from the sign of x, which assumes a convention this file need not follow."""
|
||||
best, bw = None, -1.0
|
||||
for k in range(4):
|
||||
nm = dj_names[Jd[i, k]]
|
||||
if Wd_[i, k] > bw and nm and nm.endswith(("_l", "_r")):
|
||||
best, bw = nm[-1], Wd_[i, k]
|
||||
return best or "l"
|
||||
|
||||
src_idx = np.where(is_hand_d)[0]
|
||||
src_pts = np.empty((len(src_idx), 3))
|
||||
for a, i in enumerate(src_idx):
|
||||
w = Wmesh_d @ np.append(Pd[i], 1.0)
|
||||
src_pts[a] = (inv_Wmesh_t @ (Md[donor_side(i)] @ w))[:3]
|
||||
|
||||
if KDTree is None:
|
||||
raise SystemExit("mathutils unavailable — run this under blender --background --python")
|
||||
kd = KDTree(len(src_pts))
|
||||
for a, p in enumerate(src_pts.tolist()): kd.insert(p, a)
|
||||
kd.balance()
|
||||
|
||||
Jt_new = np.zeros_like(Jt); Wt_new = np.zeros_like(Wt_)
|
||||
# body vertices keep their weights, remapped to the new joint ordering
|
||||
for i in range(len(Pt)):
|
||||
if is_hand_t[i]: continue
|
||||
for k in range(4):
|
||||
nm = tj_names[Jt[i, k]] if Jt[i, k] < len(tj_names) else None
|
||||
if nm and nm in new_index and Wt_[i, k] > 0:
|
||||
Jt_new[i, k] = new_index[nm]; Wt_new[i, k] = Wt_[i, k]
|
||||
moved = 0
|
||||
dists = []
|
||||
for i in np.where(is_hand_t)[0]:
|
||||
a = kd.find(tuple(Pt[i]))[1]
|
||||
dists.append(kd.find(tuple(Pt[i]))[2])
|
||||
s = src_idx[a]
|
||||
for k in range(4):
|
||||
nm = dj_names[Jd[s, k]]
|
||||
if Wd_[s, k] > 0 and nm in new_index:
|
||||
Jt_new[i, k] = new_index[nm]; Wt_new[i, k] = Wd_[s, k]
|
||||
moved += 1
|
||||
sums = Wt_new.sum(axis=1, keepdims=True)
|
||||
Wt_new = np.where(sums > 0, Wt_new / np.maximum(sums, 1e-12), Wt_new)
|
||||
if verbose and dists:
|
||||
d = np.array(dists)
|
||||
print(f"[weights] {moved:,} target hand verts sourced from {len(src_idx):,} donor hand "
|
||||
f"verts | nearest-donor distance mean {d.mean():.6f} p99 {np.percentile(d,99):.6f} "
|
||||
f"max {d.max():.6f}")
|
||||
|
||||
# ---- 4. inverse-bind matrices for the whole (reordered) skin
|
||||
par_t = gt.parents()
|
||||
ibm = np.empty((len(joints_new), 4, 4))
|
||||
for k, j in enumerate(joints_new):
|
||||
ibm[k] = np.linalg.inv(gt.world(j, par_t)) @ Wmesh_t
|
||||
skin_t["joints"] = joints_new
|
||||
skin_t["inverseBindMatrices"] = gt.add(
|
||||
ibm.transpose(0, 2, 1).reshape(-1, 16).astype(np.float32), "MAT4")
|
||||
prim_t["attributes"]["JOINTS_0"] = gt.add(Jt_new.astype(np.uint16), "VEC4")
|
||||
prim_t["attributes"]["WEIGHTS_0"] = gt.add(Wt_new.astype(np.float32), "VEC4")
|
||||
|
||||
# ---- 5. remove the hand chains we replaced. IBMs are keyed by position in skin.joints, and
|
||||
# prune preserves that order, so they stay valid across the reindex.
|
||||
if dropped:
|
||||
prune_nodes(gt, dropped)
|
||||
if verbose: print(f"[prune] removed {len(dropped)} replaced hand nodes")
|
||||
gt.save(out)
|
||||
if verbose:
|
||||
print(f"[done] {out} ({os.path.getsize(out)/1e6:.2f} MB, {len(joints_new)} joints)")
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- self-tests
|
||||
def selftest(donor, tmp):
|
||||
"""Two synthetic tests, because the real input (an AccuRig FBX with no hands) does not exist
|
||||
yet. Both use the donor as its own target, so the correct answer is known exactly.
|
||||
|
||||
A. IDENTITY — strip the hand chains, graft them back, expect the original rest poses and
|
||||
weights to return. Validates ordering, re-parenting, IBMs and weight remap.
|
||||
B. SIMILARITY — same, but the target is first scaled and rotated by a known amount. The
|
||||
graft must land the hands on the transformed wrist, which is what the real
|
||||
cross-body case needs (AccuRig output differs in scale and orientation).
|
||||
"""
|
||||
ok = True
|
||||
ref = Gltf(donor)
|
||||
ref_names = [ref.js["nodes"][j].get("name") for j in ref.js["skins"][0]["joints"]]
|
||||
par = ref.parents()
|
||||
ref_world = {nm: ref.world(ref.js["skins"][0]["joints"][k], par)
|
||||
for k, nm in enumerate(ref_names)}
|
||||
|
||||
for label, scale, deg in (("A identity", 1.0, 0.0), ("B similarity", 0.55, 7.0)):
|
||||
tgt = os.path.join(tmp, f"selftest_{label.split()[0]}_target.glb")
|
||||
g = Gltf(donor)
|
||||
# transform the whole target by a known similarity, applied at the scene roots
|
||||
if scale != 1.0 or deg != 0.0:
|
||||
c, s_ = math.cos(math.radians(deg)), math.sin(math.radians(deg))
|
||||
R = np.array([[c, 0, s_, 0], [0, 1, 0, 0], [-s_, 0, c, 0], [0, 0, 0, 1]])
|
||||
S = np.eye(4); S[:3, :3] *= scale
|
||||
X = R @ S
|
||||
roots = set(range(len(g.js["nodes"]))) - set(g.parents().keys())
|
||||
for r in roots:
|
||||
L = X @ g.local(r)
|
||||
t, q = decompose_unit(L)
|
||||
sc = np.linalg.norm(L[:3, 0])
|
||||
g.js["nodes"][r].pop("matrix", None)
|
||||
g.js["nodes"][r]["translation"] = [float(v) for v in t]
|
||||
g.js["nodes"][r]["rotation"] = [float(v) for v in q]
|
||||
g.js["nodes"][r]["scale"] = [float(sc)] * 3
|
||||
# strip the hand chains from the target's skin (simulating the hands-off bait)
|
||||
keep = [j for j, nm in zip(g.js["skins"][0]["joints"],
|
||||
[g.js["nodes"][x].get("name") for x in g.js["skins"][0]["joints"]])
|
||||
if not (nm.startswith(("index", "middle", "ring", "pinky", "thumb")))]
|
||||
names_keep = [g.js["nodes"][j].get("name") for j in keep]
|
||||
mi, pi = g.body_prim(); prim = g.js["meshes"][mi]["primitives"][pi]
|
||||
J = np.array(g.read(prim["attributes"]["JOINTS_0"]), dtype=np.int64)
|
||||
W = np.array(g.read(prim["attributes"]["WEIGHTS_0"]), dtype=np.float64)
|
||||
old_names = [g.js["nodes"][j].get("name") for j in g.js["skins"][0]["joints"]]
|
||||
ni = {nm: k for k, nm in enumerate(names_keep)}
|
||||
J2 = np.zeros_like(J); W2 = np.zeros_like(W)
|
||||
for i in range(len(J)):
|
||||
for k in range(4):
|
||||
nm = old_names[J[i, k]]
|
||||
# finger weights collapse onto the wrist, as a hands-off rig would have them
|
||||
nm = nm if nm in ni else ("hand_l" if nm.endswith("_l") else "hand_r")
|
||||
J2[i, k] = ni[nm]; W2[i, k] = W[i, k]
|
||||
ibm_old = g.read(g.js["skins"][0]["inverseBindMatrices"]).reshape(-1, 4, 4)
|
||||
keepidx = [old_names.index(nm) for nm in names_keep]
|
||||
g.js["skins"][0]["joints"] = keep
|
||||
g.js["skins"][0]["inverseBindMatrices"] = g.add(
|
||||
ibm_old[keepidx].reshape(-1, 16).astype(np.float32), "MAT4")
|
||||
prim["attributes"]["JOINTS_0"] = g.add(J2.astype(np.uint16), "VEC4")
|
||||
prim["attributes"]["WEIGHTS_0"] = g.add(W2.astype(np.float32), "VEC4")
|
||||
g.save(tgt)
|
||||
|
||||
out = os.path.join(tmp, f"selftest_{label.split()[0]}_out.glb")
|
||||
print(f"\n=== selftest {label} (target scaled x{scale}, rotated {deg} deg)")
|
||||
graft(tgt, donor, out)
|
||||
|
||||
r = Gltf(out)
|
||||
names = [r.js["nodes"][j].get("name") for j in r.js["skins"][0]["joints"]]
|
||||
if names != ref_names:
|
||||
print(f" FAIL joint order differs ({len(names)} vs {len(ref_names)})"); ok = False
|
||||
else:
|
||||
print(f" PASS joint order — {len(names)} joints, canonical")
|
||||
# hand rest poses, compared in the target's own frame (undo the known transform)
|
||||
parr = r.parents()
|
||||
worst, worstn = 0.0, ""
|
||||
for k, nm in enumerate(names):
|
||||
if not (nm.startswith(("index", "middle", "ring", "pinky", "thumb")) or nm in HAND_ROOTS):
|
||||
continue
|
||||
Wg = r.world(r.js["skins"][0]["joints"][k], parr)
|
||||
# expected: reference world transformed by the same similarity, scale stripped
|
||||
c, s_ = math.cos(math.radians(deg)), math.sin(math.radians(deg))
|
||||
R = np.array([[c, 0, s_, 0], [0, 1, 0, 0], [-s_, 0, c, 0], [0, 0, 0, 1]])
|
||||
S = np.eye(4); S[:3, :3] *= scale
|
||||
exp = R @ S @ ref_world[nm]
|
||||
d = np.linalg.norm(Wg[:3, 3] - exp[:3, 3])
|
||||
if d > worst: worst, worstn = d, nm
|
||||
tol = 1e-5 * max(scale, 1e-3)
|
||||
print(f" {'PASS' if worst < 1e-4 else 'FAIL'} hand joint placement — worst origin error "
|
||||
f"{worst:.3e} u at {worstn}")
|
||||
ok &= worst < 1e-4
|
||||
# weights: every hand vertex should recover the donor's own weights
|
||||
mi, pi = r.body_prim(); pr = r.js["meshes"][mi]["primitives"][pi]
|
||||
Jn = np.array(r.read(pr["attributes"]["JOINTS_0"]), dtype=np.int64)
|
||||
Wn = np.array(r.read(pr["attributes"]["WEIGHTS_0"]), dtype=np.float64)
|
||||
mi0, pi0 = ref.body_prim(); pr0 = ref.js["meshes"][mi0]["primitives"][pi0]
|
||||
J0 = np.array(ref.read(pr0["attributes"]["JOINTS_0"]), dtype=np.int64)
|
||||
W0 = np.array(ref.read(pr0["attributes"]["WEIGHTS_0"]), dtype=np.float64)
|
||||
P0 = np.array(ref.read(pr0["attributes"]["POSITION"]), dtype=np.float64)
|
||||
half = np.abs(P0[:, 0]).max()
|
||||
hand = np.abs(P0[:, 0]) > 0.756 * half
|
||||
def as_dict(J, W, i):
|
||||
return {J[i, k]: round(float(W[i, k]), 4) for k in range(4) if W[i, k] > 1e-6}
|
||||
same = sum(1 for i in np.where(hand)[0] if as_dict(Jn, Wn, i) == as_dict(J0, W0, i))
|
||||
tot = int(hand.sum())
|
||||
print(f" {'PASS' if same == tot else 'WARN'} hand weights recovered exactly on "
|
||||
f"{same:,}/{tot:,} hand verts ({100*same/max(tot,1):.2f}%)")
|
||||
wsum = Wn.sum(axis=1)
|
||||
print(f" {'PASS' if abs(wsum-1).max() < 1e-3 else 'FAIL'} weights normalised "
|
||||
f"(max deviation {abs(wsum-1).max():.2e})")
|
||||
ok &= abs(wsum - 1).max() < 1e-3
|
||||
print(f"\nSELFTEST {'PASS' if ok else 'FAIL'}")
|
||||
return 0 if ok else 2
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else sys.argv[1:]
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--target"); ap.add_argument("--donor", required=True)
|
||||
ap.add_argument("--out"); ap.add_argument("--selftest", action="store_true")
|
||||
ap.add_argument("--tmp", default=".")
|
||||
a = ap.parse_args(argv)
|
||||
if a.selftest:
|
||||
raise SystemExit(selftest(a.donor, a.tmp))
|
||||
if not a.target or not a.out:
|
||||
raise SystemExit("--target and --out are required unless --selftest")
|
||||
graft(a.target, a.donor, a.out)
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"source": "C:\\Users\\Jeremy\\tinqs\\ariki-game\\assets\\quaternius\\derived-bodies\\Mako_Fullhead_QuatSkin_candidate.glb",
|
||||
"units": "meters (glTF)",
|
||||
"height_total": 1.818,
|
||||
"chest_circ": 1.3592,
|
||||
"chest_z": 1.2992,
|
||||
"waist_circ": 0.8868,
|
||||
"waist_z": 1.065,
|
||||
"hip_circ": 1.0143,
|
||||
"hip_z": 0.9321,
|
||||
"thigh_circ": 0.6726,
|
||||
"thigh_z": 0.813,
|
||||
"neck_circ": 0.6052,
|
||||
"neck_z": 1.5661,
|
||||
"bicep_circ": 0.5922,
|
||||
"shoulder_width": 0.3838,
|
||||
"arm_len_shoulder_to_wrist": 0.5471,
|
||||
"nape_to_pelvis": 0.5709,
|
||||
"crotch_height": 0.9321,
|
||||
"pelvis_height": 0.9167,
|
||||
"knee_height": 0.5318,
|
||||
"ankle_height": 0.1037,
|
||||
"shoulder_z": 1.4579,
|
||||
"notes": {
|
||||
"verified": "Torso circumference profile re-run independently 2026-08-12; chest/waist/hip/thigh are real body geometry (dominant groups all spine_*/pelvis/thigh_*, no arm intrusion at T-pose).",
|
||||
"neck_circ_unreliable": "neck_circ/neck_z measure the GRAFTED FULL-RES HEAD, not the neck. The scan's min landed at z=1.566 where the slice is 340+ verts of the Head group (hair/jaw), reading 60.6 cm. Do not draft a collar to this. The body mesh's own neck stump narrows to 51 cm at z=1.525 and the body geometry ENDS at z=1.545 - above that is all Head group. Treat z=1.52-1.55 as the collar ceiling.",
|
||||
"body_mesh_top_z": 1.545,
|
||||
"shape_vs_lena": "Inverted triangle where Lena is a pear. chest +27 cm, waist +21 cm, hip -8 cm, thigh -17 cm, shoulder_width +6 cm vs Ariki_Female_QuatSkin. Lena garment configs will bind at the chest and hang loose at the hips - redraft, do not re-drape.",
|
||||
"vert_budget": "122,067 verts total: Head 81,552 (full-res, includes hair), hand_l/r 22,472 (rigid mitts), torso+limbs the remainder. Heavy avatar for MD - simulate with patience.",
|
||||
"md_avatar_fbx": "tools/tailor/avatar/Mako_Fullhead_QuatSkin_Avatar.fbx (this GLB, Icosphere stray dropped, leaf bones stripped). Blender-exported so import with op.scale = 10.0."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
make_hunter_cloth.py -- coarse woven brown cloth for the hunter wrap skirt.
|
||||
|
||||
python tools/tailor/textures/make_hunter_cloth.py
|
||||
|
||||
Derived from the reference renders
|
||||
`male-clothing-hunter-gpt-v2{,-side,-back}.png`: a dark warm-brown coarse plain
|
||||
weave, roughly burlap/harakeke-sack in character, with visible thread grain and
|
||||
wear mottling.
|
||||
|
||||
WHY THE COLOUR IS BAKED IN, NOT GREY
|
||||
The clothing pipeline has NO alpha and `apply_fabric_texture()` wires the PNG
|
||||
straight into Base Color, *replacing* the part's flat `color` rather than
|
||||
multiplying it. So a grayscale weave renders grey in-game, not brown. Every
|
||||
value below is a final albedo, not a mask.
|
||||
|
||||
WHY THESE RGB NUMBERS
|
||||
Sampled from the reference renders: garment mean (56,35,26) with highlights to
|
||||
about (92,61,45). Those are *lit* pixels from a dim studio setup, so the albedo
|
||||
sits above them -- BASE is set brighter so that in-game lighting lands the
|
||||
garment back on the reference's apparent tone instead of crushing it to near
|
||||
black. Warm ramp throughout: r > g > b, r-b about 45.
|
||||
|
||||
WHY DPI AND NOT IMAGE SCALE
|
||||
In MD the PNG's DPI sets the cloth's physical size, so tiling is controlled by
|
||||
dpi, never by resizing the image. This tile represents CLOTH_MM of fabric:
|
||||
dpi = SIZE / (CLOTH_MM / 25.4). At 100 mm it repeats about 7x across the skirt's
|
||||
687 mm hem, which is what keeps the weave reading as thread rather than pattern.
|
||||
|
||||
DETERMINISM
|
||||
No `random` and no time source -- a fixed LCG plus a fixed hash, so the file is
|
||||
byte-identical run to run. This PNG is a build input; a texture that changes
|
||||
under you turns a placement regression into a wild goose chase.
|
||||
"""
|
||||
import os
|
||||
|
||||
from PIL import Image
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
OUT = os.path.join(HERE, "hunter_cloth.png")
|
||||
|
||||
SIZE = 512 # px, square
|
||||
CLOTH_MM = 100.0 # physical span this tile represents
|
||||
DPI = SIZE / (CLOTH_MM / 25.4)
|
||||
|
||||
BASE = (108, 80, 60) # mid warm brown albedo
|
||||
THREAD_PITCH = 16 # px per thread; 512px/100mm -> ~3.1 mm threads (coarse)
|
||||
OVER_LIFT = 16 # threads on top of the weave are lighter
|
||||
UNDER_DROP = 20 # threads passing under are shaded
|
||||
ROUND_SHADE = 14 # cross-thread rounding falloff
|
||||
SLUB_RANGE = 10 # per-thread thickness/tone irregularity
|
||||
MOTTLE = 16 # large-scale wear variation
|
||||
MOTTLE_CELL = 64 # px per mottle cell
|
||||
|
||||
|
||||
def lcg(seed):
|
||||
"""Deterministic 0..1 sequence. Fixed constants (glibc), fixed seed."""
|
||||
state = seed
|
||||
while True:
|
||||
state = (1103515245 * state + 12345) % (2 ** 31)
|
||||
yield state / float(2 ** 31)
|
||||
|
||||
|
||||
def thread_tones(n, seed):
|
||||
"""One slub value per thread, so a thread's irregularity runs its length --
|
||||
per-pixel noise would read as sand, not as spun fibre."""
|
||||
g = lcg(seed)
|
||||
return [int((next(g) * 2.0 - 1.0) * SLUB_RANGE) for _ in range(n)]
|
||||
|
||||
|
||||
def value_noise(cells, seed):
|
||||
"""Coarse lattice of values, bilinearly interpolated -> smooth wear blotches."""
|
||||
g = lcg(seed)
|
||||
grid = [[(next(g) * 2.0 - 1.0) for _ in range(cells + 1)] for _ in range(cells + 1)]
|
||||
|
||||
def sample(x, y):
|
||||
fx, fy = x * cells / SIZE, y * cells / SIZE
|
||||
x0, y0 = int(fx), int(fy)
|
||||
tx, ty = fx - x0, fy - y0
|
||||
# smoothstep so cell borders don't show as creases
|
||||
tx, ty = tx * tx * (3 - 2 * tx), ty * ty * (3 - 2 * ty)
|
||||
a = grid[y0][x0] * (1 - tx) + grid[y0][x0 + 1] * tx
|
||||
b = grid[y0 + 1][x0] * (1 - tx) + grid[y0 + 1][x0 + 1] * tx
|
||||
return a * (1 - ty) + b * ty
|
||||
|
||||
return sample
|
||||
|
||||
|
||||
def main():
|
||||
n_threads = SIZE // THREAD_PITCH
|
||||
warp = thread_tones(n_threads, seed=20260812)
|
||||
weft = thread_tones(n_threads, seed=90210)
|
||||
mottle = value_noise(SIZE // MOTTLE_CELL, seed=5150)
|
||||
|
||||
img = Image.new("RGB", (SIZE, SIZE))
|
||||
px = img.load()
|
||||
|
||||
for y in range(SIZE):
|
||||
j = (y // THREAD_PITCH) % n_threads
|
||||
# position across the weft thread, -1..1, for rounding
|
||||
vy = ((y % THREAD_PITCH) / (THREAD_PITCH - 1.0)) * 2.0 - 1.0
|
||||
for x in range(SIZE):
|
||||
i = (x // THREAD_PITCH) % n_threads
|
||||
vx = ((x % THREAD_PITCH) / (THREAD_PITCH - 1.0)) * 2.0 - 1.0
|
||||
|
||||
# plain weave: alternate which thread sits on top
|
||||
warp_on_top = ((i + j) % 2) == 0
|
||||
if warp_on_top:
|
||||
lift = OVER_LIFT - int(ROUND_SHADE * vx * vx)
|
||||
slub = warp[i]
|
||||
else:
|
||||
lift = -UNDER_DROP + int(ROUND_SHADE * (1.0 - vy * vy))
|
||||
slub = weft[j]
|
||||
|
||||
wear = int(mottle(x, y) * MOTTLE)
|
||||
d = lift + slub + wear
|
||||
|
||||
# warm ramp: brown shifts warmer as it lightens, cooler in shadow
|
||||
r = BASE[0] + d
|
||||
g = BASE[1] + int(d * 0.78)
|
||||
b = BASE[2] + int(d * 0.62)
|
||||
px[x, y] = (max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b)))
|
||||
|
||||
img.save(OUT, dpi=(DPI, DPI))
|
||||
|
||||
vals = [px[x, y] for y in range(0, SIZE, 8) for x in range(0, SIZE, 8)]
|
||||
n = len(vals)
|
||||
mean = tuple(sum(v[c] for v in vals) // n for c in range(3))
|
||||
print("wrote %s (%dx%d, dpi %.1f -> %.0f mm of cloth)"
|
||||
% (OUT, SIZE, SIZE, DPI, CLOTH_MM))
|
||||
print("mean albedo %s (reference lit mean was (56,35,26))" % (mean,))
|
||||
print("range r %d-%d g %d-%d b %d-%d"
|
||||
% (min(v[0] for v in vals), max(v[0] for v in vals),
|
||||
min(v[1] for v in vals), max(v[1] for v in vals),
|
||||
min(v[2] for v in vals), max(v[2] for v in vals)))
|
||||
warm = mean[0] - mean[2]
|
||||
print("warmth r-b = %d (reference %d)" % (warm, 56 - 26))
|
||||
if warm < 30:
|
||||
print("WARNING: not warm enough -- will read as grey cloth")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user