feat(lena): v02 lane recipes + the archival full-res master

The v02 chain, which exists because bisecting the shard-eye defect proved it lives in
the hires MASTER rather than in any v02 step: the early heal chain ran whole-mesh welds
and reset custom split normals, and the eye/lash shells only read as an eye through
Tripo's authored normals. Every descendant inherits it, including the shipped body.

  48_decimate_headsafe  body/hands only, head bit-identical (feathered ramp)
  49_seams_v02          24_seams with a density-aware hip landmark — the v01 rule fired
                        at u=0.610, mid-belly, on the head-protected vert count
  50_seams_headuv       body-only unwrap; the head KEEPS its original Tripo charts.
                        SLIM collapsed the undecimated lash/brow slivers to points and
                        ANGLE_BASED packed at half v01's texel density; the face was
                        the best-mapped region of the source atlas, so it is reused
  51_reatlas_v02        31_reatlas + centroid splats for sub-texel triangles — the
                        skipped set IS the lashes, which rendered as grey glass
  52_head_transplant    the PRISTINE ORIGINAL head onto the decimated nude body
  53_rig_transfer       54_crotch_refill

55_fullres_v02.blend is pinned in .lanekeep as THE archival master: full-res nude body
+ pristine original head, crotch refill and texture despeckle applied, 883,404 v /
1,761,640 f. It supersedes 34_v04 as the lane root (34_v04's head has the shard eyes).

Also: tools/graft_hands.py, the Marvelous Designer hunter-skirt configs v1-v8, the
hunter cloth texture generator, and Mako's measurement card.

Per .agents/rules/working-files.md the per-attempt .blend files under work/lena/v02
are SCRATCH ("never committed") — the .py recipes here are the history and regenerate
any of them from the pinned master. See the ignore rule landing next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 18:53:17 -07:00
parent 23fc4b378e
commit c12c4f156c
17 changed files with 3828 additions and 0 deletions
+310
View File
@@ -0,0 +1,310 @@
# Stage 51 (v02): 31_reatlas.py + centroid splats for sub-texel triangles.
#
# The head-protected v02 body keeps Tripo's eyelash/eye/lip SHELLS at full density — thousands of
# triangles smaller than one texel. The v01 transfer just skipped those (0.58% of surface) and let
# the padding pass cover them, which was invisible when the skipped area was random slivers. Here
# the skipped set IS the lashes: they rendered as grey glass because their texels held padding
# smear instead of lash. Every sub-texel triangle now SPLATS its centroid: one point-sample of the
# source maps written to its one destination texel — so a lash texel holds lash.
# Stage 31: resample the existing maps into the new anatomical atlas, then export the re-atlased
# body.
#
# blender --background --python 31_reatlas.py -- <seamed.blend> <out_dir> <out.glb>
#
# Input is 24_seams.py's output, which carries TWO uv layers: 'UVMap_tripo' (the source 5,870-chart
# atlas, still holding the textures) and 'UVMap_atlas' (the new ~14-chart layout, empty). This walks
# every triangle in NEW atlas space, barycentrically recovers the OLD uv per texel, and samples the
# source maps there. Same mesh, same triangles, so the transfer is exact — no projection, no BVH,
# no Cycles bake.
#
# The normal map cannot simply be copied. It is tangent-space, and re-atlasing rotates (and
# sometimes mirrors) every chart, so its frame moves. Per face this computes the old and new
# tangent frames about the shared geometric normal and rotates the stored vector between them;
# skipping that would light her detail from the wrong direction, subtly and everywhere.
import bpy, sys, os, time
import numpy as np
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, OUTDIR, OUTGLB = argv[0], os.path.abspath(argv[1]), os.path.abspath(argv[2])
os.makedirs(OUTDIR, exist_ok=True)
RES = int(argv[3]) if len(argv) > 3 else 4096
PAD = 16
t0 = time.time()
def log(m):
print(f"[atlas {time.time()-t0:6.1f}s] {m}", flush=True)
bpy.ops.wm.open_mainfile(filepath=BLEND)
ob = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
me = ob.data
names = [l.name for l in me.uv_layers]
assert "UVMap_tripo" in names and "UVMap_atlas" in names, f"expected both uv layers, got {names}"
n_v, n_l, n_f = len(me.vertices), len(me.loops), len(me.polygons)
log(f"mesh {n_v}v {n_f}f uv layers {names}")
# ---- source maps, resolved through the material graph (the file holds stale duplicates) ----
src = {}
for slot in ob.material_slots:
mat = slot.material
if not mat or not mat.node_tree:
continue
for node in mat.node_tree.nodes:
if node.type != 'BSDF_PRINCIPLED':
continue
for sock, key in (("Base Color", "base"), ("Normal", "normal"), ("Roughness", "rm")):
if sock not in node.inputs or not node.inputs[sock].links:
continue
nd = node.inputs[sock].links[0].from_node
seen = set()
while nd and nd.type != 'TEX_IMAGE' and id(nd) not in seen:
seen.add(id(nd))
nxt = None
for i in nd.inputs:
if i.links:
nxt = i.links[0].from_node
break
nd = nxt
if nd and nd.type == 'TEX_IMAGE' and nd.image:
src[key] = nd.image
for k, im in src.items():
log(f"source {k}: '{im.name}' {im.size[0]}x{im.size[1]}")
assert "base" in src, "no basecolor found"
def px_of(img):
w, h = img.size
b = np.empty(w * h * 4, dtype=np.float32)
img.pixels.foreach_get(b)
return b.reshape(h, w, 4)[:, :, :3].astype(np.float32), w, h
# Colour management is not cosmetic here. `image.pixels` hands back linear values for an sRGB
# image and raw values for a Non-Color one, and re-encodes the same way on save. Write raw
# normal/rm data into a default (sRGB) image and saving gamma-encodes it: 0.5 comes back as 0.21,
# so every stored normal becomes a large false perturbation and the body shades dark and glossy.
# Each new map must therefore inherit its source's colorspace exactly.
for k, im in src.items():
log(f" {k} colorspace: {im.colorspace_settings.name}")
# ---- geometry + both uv sets ----
loops_v = np.empty(n_l, dtype=np.int32); me.loops.foreach_get("vertex_index", loops_v)
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
l_start = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_start", l_start)
l_tot = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_total", l_tot)
li = l_start[l_tot == 3]
nT = len(li)
def uv_of(name):
a = np.empty(n_l * 2)
me.uv_layers[name].data.foreach_get("uv", a)
return a.reshape(-1, 2)
UO = uv_of("UVMap_tripo")
UN = uv_of("UVMap_atlas")
IDX = np.stack([li, li + 1, li + 2], axis=1)
VI = loops_v[IDX] # (T,3) vertex index
Qo = np.clip(UO[IDX], 0.0, 1.0) # (T,3,2) source uv
Pn = np.clip(UN[IDX], 0.0, 1.0) # (T,3,2) new uv
P3 = co[VI] # (T,3,3)
log(f"{nT} triangles")
# ---- per-face tangent frames, for the normal-map rotation ----
e1 = P3[:, 1] - P3[:, 0]
e2 = P3[:, 2] - P3[:, 0]
N = np.cross(e1, e2)
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-20)
def tangent(Q):
d1 = Q[:, 1] - Q[:, 0]
d2 = Q[:, 2] - Q[:, 0]
det = d1[:, 0] * d2[:, 1] - d2[:, 0] * d1[:, 1]
r = np.where(np.abs(det) < 1e-20, 0.0, 1.0 / np.where(det == 0, 1.0, det))
T = (e1 * d2[:, 1:2] - e2 * d1[:, 1:2]) * r[:, None]
B = (e2 * d1[:, 0:1] - e1 * d2[:, 0:1]) * r[:, None]
# orthonormalise against the shared geometric normal
T = T - N * (N * T).sum(axis=1, keepdims=True)
ln = np.linalg.norm(T, axis=1, keepdims=True)
bad = (ln[:, 0] < 1e-12)
T = np.where(bad[:, None], np.cross(N, [0.0, 0.0, 1.0]), T / np.maximum(ln, 1e-20))
ln = np.maximum(np.linalg.norm(T, axis=1, keepdims=True), 1e-20)
T = T / ln
w = np.sign((np.cross(N, T) * B).sum(axis=1))
w = np.where(w == 0, 1.0, w)
return T, np.cross(N, T) * w[:, None]
To, Bo = tangent(Qo)
Tn, Bn = tangent(Pn)
# (nx',ny') = M . (nx,ny) — both frames share N, so nz is unchanged
M00 = (To * Tn).sum(axis=1); M01 = (Bo * Tn).sum(axis=1)
M10 = (To * Bn).sum(axis=1); M11 = (Bo * Bn).sum(axis=1)
rot = np.degrees(np.arctan2(M10, M00))
log(f"tangent frame rotation between atlases: p50 {np.percentile(np.abs(rot),50):.1f} deg, "
f"p95 {np.percentile(np.abs(rot),95):.1f} deg, max {np.abs(rot).max():.1f} deg")
# ---- rasterise the new atlas ----
W = H = RES
out = {k: np.zeros((H, W, 3), dtype=np.float32) for k in src}
srcpx = {k: px_of(v) for k, v in src.items()}
mask = np.zeros((H, W), dtype=bool)
def bilinear(A, w, h, uu, vv):
x = np.clip(uu, 0, 1) * (w - 1)
y = np.clip(vv, 0, 1) * (h - 1)
x0 = np.floor(x).astype(np.int32); y0 = np.floor(y).astype(np.int32)
x1 = np.minimum(x0 + 1, w - 1); y1 = np.minimum(y0 + 1, h - 1)
fx = (x - x0)[:, None]; fy = (y - y0)[:, None]
return (A[y0, x0] * (1 - fx) * (1 - fy) + A[y0, x1] * fx * (1 - fy)
+ A[y1, x0] * (1 - fx) * fy + A[y1, x1] * fx * fy)
Ppx = Pn * (W - 1)
TOL = -0.02 # slight over-rasterisation so charts have no interior gaps
done = 0
hitlist = []
for f in range(nT):
P = Ppx[f]
x0 = int(P[:, 0].min()); x1 = int(np.ceil(P[:, 0].max()))
y0 = int(P[:, 1].min()); y1 = int(np.ceil(P[:, 1].max()))
if x1 < x0 or y1 < y0 or (x1 - x0) > 512 or (y1 - y0) > 512:
continue
det = ((P[1, 1] - P[2, 1]) * (P[0, 0] - P[2, 0])
+ (P[2, 0] - P[1, 0]) * (P[0, 1] - P[2, 1]))
if abs(det) < 1e-12:
continue
gx, gy = np.meshgrid(np.arange(max(x0, 0), min(x1, W - 1) + 1),
np.arange(max(y0, 0), min(y1, H - 1) + 1))
if gx.size == 0:
continue
a = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / det
b = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / det
c = 1.0 - a - b
ins = (a >= TOL) & (b >= TOL) & (c >= TOL)
if not ins.any():
# sub-texel triangle: splat its centroid instead of abandoning it to the padding pass
cx = int(np.clip(round(float(P[:, 0].mean())), 0, W - 1))
cy = int(np.clip(round(float(P[:, 1].mean())), 0, H - 1))
ou = np.array([Qo[f, :, 0].mean()])
ov = np.array([Qo[f, :, 1].mean()])
for k, (A, w, h) in srcpx.items():
val = bilinear(A, w, h, ou, ov)
if k == "normal":
nx = val[:, 0] * 2.0 - 1.0
ny = val[:, 1] * 2.0 - 1.0
val = np.stack([(nx * M00[f] + ny * M01[f]) * 0.5 + 0.5,
(nx * M10[f] + ny * M11[f]) * 0.5 + 0.5,
val[:, 2]], axis=1)
out[k][cy, cx] = val[0]
mask[cy, cx] = True
hitlist.append(f)
done += 1
continue
# Write the slightly-outside rim pixels (TOL) but SAMPLE from strictly inside the source
# triangle — the old atlas is 38% empty, and extrapolating past a source triangle's edge
# reads black gap and leaves a dark fringe on every chart border.
aa = np.clip(a[ins], 0.0, 1.0); bb = np.clip(b[ins], 0.0, 1.0); cc = np.clip(c[ins], 0.0, 1.0)
tot = np.maximum(aa + bb + cc, 1e-12)
aa, bb, cc = aa / tot, bb / tot, cc / tot
ou = aa * Qo[f, 0, 0] + bb * Qo[f, 1, 0] + cc * Qo[f, 2, 0]
ov = aa * Qo[f, 0, 1] + bb * Qo[f, 1, 1] + cc * Qo[f, 2, 1]
yy, xx = gy[ins], gx[ins]
for k, (A, w, h) in srcpx.items():
val = bilinear(A, w, h, ou, ov)
if k == "normal":
nx = val[:, 0] * 2.0 - 1.0
ny = val[:, 1] * 2.0 - 1.0
val = np.stack([(nx * M00[f] + ny * M01[f]) * 0.5 + 0.5,
(nx * M10[f] + ny * M11[f]) * 0.5 + 0.5,
val[:, 2]], axis=1)
out[k][yy, xx] = val
mask[yy, xx] = True
hitlist.append(f)
done += 1
skipped = np.ones(nT, dtype=bool)
skipped[hitlist] = False
a3 = 0.5 * np.linalg.norm(np.cross(e1, e2), axis=1)
UNITM = 1.777 / (co[:, 2].max() - co[:, 2].min())
log(f"rasterised {done}/{nT} triangles -> {int(mask.sum())} texels "
f"({100.0*mask.sum()/(W*H):.1f}% of the atlas)")
log(f"skipped {int(skipped.sum())} triangles holding "
f"{a3[skipped].sum()*UNITM*UNITM*1e4:.2f} cm2 of "
f"{a3.sum()*UNITM*UNITM*1e4:.0f} cm2 total ({100.0*a3[skipped].sum()/a3.sum():.3f}%) "
f"— they are sub-texel slivers, covered by the padding pass")
# ---- pad outward so filtering and mip generation never pull in empty space ----
have = mask.copy()
for k in out:
C = out[k]
hv = mask.copy()
for _ in range(PAD):
acc = np.zeros_like(C)
wac = np.zeros((H, W), dtype=np.float32)
Wf = hv.astype(np.float32)
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
acc += np.roll(C * Wf[:, :, None], (dy, dx), axis=(0, 1))
wac += np.roll(Wf, (dy, dx), axis=(0, 1))
new = (~hv) & (wac > 0)
if not new.any():
break
C[new] = acc[new] / wac[new, None]
hv |= new
have = hv
log(f"padded to {int(have.sum())} texels ({100.0*have.sum()/(W*H):.1f}%)")
# ---- write the maps, rewire the material, drop the source uv layer ----
newimg = {}
for k in out:
im = bpy.data.images.new(f"lena_nude_atlas_{k}", W, H, alpha=False,
is_data=(src[k].colorspace_settings.name != 'sRGB'))
im.colorspace_settings.name = src[k].colorspace_settings.name
buf = np.ones((H, W, 4), dtype=np.float32)
buf[:, :, :3] = np.clip(out[k], 0, 1)
im.pixels.foreach_set(buf.reshape(-1))
im.file_format = 'JPEG' # the source maps are JPEG; match them so the GLB stays small
p = os.path.join(OUTDIR, f"lena_nude_atlas_{k}.jpg")
im.filepath_raw = p
im.save(filepath=p)
im.pack()
newimg[k] = im
log(f"wrote {p}")
for slot in ob.material_slots:
mat = slot.material
if not mat or not mat.node_tree:
continue
for node in mat.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image:
for k, old in src.items():
if node.image == old:
node.image = newimg[k]
# Ship the body double-sided. The glTF material arrives single-sided, so Blender culls backfaces —
# and this mesh has patches wound inward (locally CONSISTENT, so `normals_make_consistent` cannot
# see them and settles at 25 stray triangles). Culled, those patches read as grey holes across her
# face and underbust in every render. Disabling culling here exports doubleSided=true and they
# disappear. Backface culling buys a character body essentially nothing.
for slot in ob.material_slots:
if slot.material:
slot.material.use_backface_culling = False
log(f"material '{slot.material.name}' -> doubleSided")
me.uv_layers.active = me.uv_layers["UVMap_atlas"]
me.uv_layers.remove(me.uv_layers["UVMap_tripo"])
me.uv_layers["UVMap_atlas"].name = "UVMap"
log(f"uv layers now {[l.name for l in me.uv_layers]}")
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUTDIR, "reatlased.blend"))
for o in bpy.data.objects:
o.select_set(o is ob)
bpy.context.view_layer.objects.active = ob
bpy.ops.export_scene.gltf(filepath=OUTGLB, export_format='GLB', use_selection=True,
export_image_format='AUTO', export_jpeg_quality=95,
export_yup=True, export_apply=False)
log(f"EXPORTED {OUTGLB} ({os.path.getsize(OUTGLB)/1e6:.1f} MB)")
print("REATLAS_DONE")