reorg(characters): ship-time folders — lena_base_v01 ships, lane moves to work/lena
REGISTRY rewritten around the central rule: a character folder is born only when a body ships to ariki-game (<character>_base_v<NN> = ship ordinal). lena_nude dissolves accordingly: - characters/female/lena_base_v01/ — SHIPPED 2026-08-10: AccuRig GLB carrier, T-pose/rig FBX + JSON, previews, frozen README - characters/work/lena/ — the live lane: recipes 01-47 (incl. new 36-47: refill/sheets/clay/despeckle/musculature/spin/AccuRig export/graft/pose QC), masters (athletic_v04 blend + textures, accurig blend), lane-history README - hires_claude/hires_work intermediates (blends, logs, probes) pruned Supporting docs: AGENTS.md, working-files rule, rig-graft plan addendum, originals README, prune_lane.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
# 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():
|
||||
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")
|
||||
Reference in New Issue
Block a user