Files
jeremy 3d8825f5a9 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>
2026-08-12 07:17:15 -07:00

347 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Stage 5: repaint the garment out of the textures (basecolor + normal + roughness/metallic),
# on the sculpted hires body.
#
# blender --background --python 05_texture.py -- <06_final.blend> <masks.npz>
# <00_welded.blend> <out.blend>
#
# The repaint mask is rebuilt from the ORIGINAL geometry (00_welded) because fabric verts are
# no longer rough after the sculpt: garment hemband key_raw rough strap corridor the
# bow-excision window the crotch box — the union of every region whose geometry was
# replaced, whose paint is fabric.
#
# Same principles that fixed the game-density bake, at hires scale:
# - fill tone comes from skin NEAREST ON THE BODY (KD over skin verts in 3D), never from
# atlas neighbourhoods — atlas-local fills gave wrong tones and island seams;
# - grain is transplanted from real skin tiles so the fill is not a smooth decal
# (per-channel high-pass — the channel-mixing blur bug is not repeated here);
# - normal map goes flat (128,128,255) and rm matches median skin over the same texels,
# so the fabric weave stops shading through after the paint is gone.
import bpy, sys, time
import numpy as np
from mathutils import Vector
from mathutils.kdtree import KDTree
argv = sys.argv[sys.argv.index("--") + 1:]
BLEND, MASKS, WELDED, OUT = argv[0], argv[1], argv[2], argv[3]
# 'patch' mode (v02 variant): repaint ONLY the garment texels, keep every other texel of the
# original skin — no rosy-chest mask, fill sources include the rosy skin so the patches blend
# with HER tones, and the fill is mirror-averaged so one side's blush can't splash one cup.
PATCH_ONLY = len(argv) > 4 and argv[4] == "patch"
t0 = time.time()
GRAIN_T = 16
FEATHER = 4
def log(m):
print(f"[tex {time.time()-t0:6.1f}s] {m}", flush=True)
# ---- build the repaint mask from ORIGINAL geometry ----
bpy.ops.wm.open_mainfile(filepath=WELDED)
ob0 = max([o for o in bpy.data.objects if o.type == 'MESH'],
key=lambda o: len(o.data.vertices))
me0 = ob0.data
n_v = len(me0.vertices)
co0 = np.empty(n_v * 3, dtype=np.float64)
me0.vertices.foreach_get("co", co0)
co0 = co0.reshape(-1, 3)
ev0 = np.empty(len(me0.edges) * 2, dtype=np.int32)
me0.edges.foreach_get("vertices", ev0)
ev0 = ev0.reshape(-1, 2)
o_r = np.concatenate([ev0[:, 0], ev0[:, 1]])
n_r = np.concatenate([ev0[:, 1], ev0[:, 0]])
s_r = np.argsort(o_r, kind="stable")
o_rs = o_r[s_r]
n_rs = n_r[s_r]
ptr_r = np.searchsorted(o_rs, np.arange(n_v + 1))
cnt_r = np.maximum(np.diff(ptr_r), 1)
sm_r = co0.copy()
for _ in range(8):
su = np.add.reduceat(sm_r[n_rs], ptr_r[:-1], axis=0)
emp = np.diff(ptr_r) == 0
su[emp] = sm_r[emp]
sm_r = su / cnt_r[:, None]
rough0 = np.linalg.norm(co0 - sm_r, axis=1)
M = np.load(MASKS)
corridor0 = (co0[:, 2] > 0.64) & (co0[:, 2] < 0.86) \
& (np.abs(co0[:, 0]) > 0.02) & (np.abs(co0[:, 0]) < 0.145)
strap_rough = corridor0 & (rough0 > 0.0005)
bow_win = (co0[:, 1] < 0) & (np.abs(co0[:, 0]) < 0.080) \
& (co0[:, 2] > 0.630) & (co0[:, 2] < 0.802)
crotch_box = (np.abs(co0[:, 0]) < 0.075) & (co0[:, 2] > 0.340) & (co0[:, 2] < 0.480)
garment = (M["garment"] | M["hemband"] | M["key_raw"]
| strap_rough | bow_win | crotch_box)
for _ in range(3): # grow so the rasterised fill overlaps every replaced-geometry rim
hit = garment[ev0[:, 0]] | garment[ev0[:, 1]]
g2 = garment.copy()
g2[ev0[:, 0]] |= hit
g2[ev0[:, 1]] |= hit
garment = g2
log(f"repaint mask: {garment.sum()} of {n_v} "
f"(strap_rough {strap_rough.sum()}, bow {bow_win.sum()}, crotch {crotch_box.sum()})")
# ---- now open the FINAL sculpted body and repaint on it ----
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
assert len(me.vertices) == n_v, "vertex count changed between welded and final"
log(f"loaded {n_v}v, garment {garment.sum()}")
co = np.empty(n_v * 3, dtype=np.float64)
me.vertices.foreach_get("co", co)
co = co.reshape(-1, 3)
imgs = {}
for i in bpy.data.images:
nm = i.name.lower()
if "basecolor" in nm:
imgs["base"] = i
elif "normal" in nm:
imgs["normal"] = i
elif "_rm" in nm or nm.endswith("rm.jpg"):
imgs["rm"] = i
log(f"images: { {k: v.name for k, v in imgs.items()} }")
base = imgs["base"]
w, h = base.size
buf = np.empty(w * h * 4, dtype=np.float32)
base.pixels.foreach_get(buf)
rgb = buf.reshape(h, w, 4)
loops_v = np.empty(len(me.loops), dtype=np.int32)
me.loops.foreach_get("vertex_index", loops_v)
uv = np.empty(len(me.loops) * 2, dtype=np.float64)
me.uv_layers.active.data.foreach_get("uv", uv)
uv = uv.reshape(-1, 2)
lx = np.clip(uv[:, 0], 0, 1) * (w - 1)
ly = np.clip(uv[:, 1], 0, 1) * (h - 1)
first_loop = np.full(n_v, len(loops_v), dtype=np.int64)
np.minimum.at(first_loop, loops_v, np.arange(len(loops_v), dtype=np.int64))
first_loop = np.minimum(first_loop, len(loops_v) - 1)
vcol = rgb[ly[first_loop].astype(int), lx[first_loop].astype(int), :3]
# ---- rosy paint: the original body has a sunburn-like blush V on the upper chest/throat.
# It reads as a tan line on the nude (uniform-skin decision) and it poisons the 3D-nearest
# fill (asymmetric pink cups). Detect it per-vertex, repaint it, and never sample from it.
band = (co[:, 2] > 0.30) & (co[:, 2] < 0.905)
rg = vcol[:, 0] - vcol[:, 1]
# reference tone is the BELLY, not the band median — the whole upper chest is rosy, so a
# band median is itself rosy-biased and lets the blush field through (measured: belly rg
# 0.239, upper chest 0.30-0.33; a med+0.05 cut only caught the extreme pink core)
belly = ~garment & (co[:, 1] < 0) & (co[:, 2] > 0.45) & (co[:, 2] < 0.60) & (np.abs(co[:, 0]) < 0.08)
med_rg = np.median(rg[belly])
rosy = band & (rg > med_rg + 0.045)
if PATCH_ONLY:
log(f"patch mode: garment texels only, mask {garment.sum()}")
else:
rosy_chest = rosy & (co[:, 1] < 0.01) & (co[:, 2] > 0.55)
garment = garment | rosy_chest
log(f"rosy: {rosy.sum()} total, chest repaint {rosy_chest.sum()} "
f"(belly med_rg {med_rg:.3f}) -> mask {garment.sum()}")
# ---- per-vertex fill colour: K nearest skin verts in 3D ----
if PATCH_ONLY:
skin = ~garment & (co[:, 2] > 0.30) & (co[:, 2] < 0.86)
else:
skin = ~garment & ~rosy & (co[:, 2] > 0.30) & (co[:, 2] < 0.86)
skin_idx = np.nonzero(skin)[0][::3] # 1-in-3 sample is plenty at this density
kd = KDTree(len(skin_idx))
for j, i in enumerate(skin_idx):
kd.insert(Vector(co[i]), j)
kd.balance()
log(f"skin KD: {len(skin_idx)} verts")
gidx = np.nonzero(garment)[0]
fill_c = vcol.copy()
def idw_at(p):
hits = kd.find_n(Vector(p), 8)
wsum = 0.0
acc = np.zeros(3)
for (_, j, dist) in hits:
wgt = 1.0 / max(dist * dist, 1e-9)
acc += wgt * vcol[skin_idx[j]]
wsum += wgt
return acc / wsum
for i in gidx:
c1 = idw_at(co[i])
if PATCH_ONLY:
# mirror-average so asymmetric blush near one cup cannot tint only that cup
c2 = idw_at([-co[i][0], co[i][1], co[i][2]])
fill_c[i] = 0.5 * (c1 + c2)
else:
fill_c[i] = c1
log("per-vertex fill colours done")
# ---- rasterise fill over garment faces ----
n_f = len(me.polygons)
l_tot = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_total", l_tot)
l_start = np.empty(n_f, dtype=np.int32)
me.polygons.foreach_get("loop_start", l_start)
gv = np.zeros(n_v, dtype=bool)
gv[gidx] = True
face_g = np.zeros(n_f, dtype=bool)
# a face is garment if ANY corner is (covers the rim); loop over faces via numpy reduceat
face_flag = np.add.reduceat(gv[loops_v].astype(np.int32), l_start)
face_g = face_flag > 0
log(f"garment faces: {face_g.sum()}")
mask_px = np.zeros((h, w), dtype=bool)
out_rgb = rgb[:, :, :3].astype(np.float64)
for fi in np.nonzero(face_g)[0]:
s, t = l_start[fi], l_tot[fi]
li = np.arange(s, s + t)
P = np.stack([lx[li], ly[li]], axis=1)
V = loops_v[li]
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 > 256 or y1 - y0 > 256 or x1 < x0 or y1 < y0:
continue
if t != 3:
P = P[:3]
V = V[:3]
d = ((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(d) < 1e-12:
continue
gx, gy = np.meshgrid(np.arange(x0, min(x1, w - 1) + 1),
np.arange(y0, min(y1, h - 1) + 1))
a = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / d
b = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / d
c = 1.0 - a - b
ins = (a >= -0.03) & (b >= -0.03) & (c >= -0.03)
if not ins.any():
continue
col = (a[ins, None] * fill_c[V[0]] + b[ins, None] * fill_c[V[1]]
+ c[ins, None] * fill_c[V[2]])
out_rgb[gy[ins], gx[ins]] = col
mask_px[gy[ins], gx[ins]] = True
log(f"rasterised fill: {mask_px.sum()} texels")
# ---- absorb mask-rim slivers (white stitch piping extends past the colour key, and UV
# island borders leave old texels between rasterised faces): dilate 8 px, propagate fill
# colours outward into the ring so no legacy pixel survives inside the dilated mask.
def dil1(m):
g = m.copy()
g[1:, :] |= m[:-1, :]
g[:-1, :] |= m[1:, :]
g[:, 1:] |= m[:, :-1]
g[:, :-1] |= m[:, 1:]
return g
dil = mask_px.copy()
for _ in range(8):
dil = dil1(dil)
ring = dil & ~mask_px
C = out_rgb.copy()
have = mask_px.copy()
for _ in range(10):
if not (ring & ~have).any():
break
Wf = have.astype(np.float64)
acc = np.zeros_like(C)
wacc = np.zeros((h, w))
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
acc += np.roll(C * Wf[:, :, None], (dy, dx), axis=(0, 1))
wacc += np.roll(Wf, (dy, dx), axis=(0, 1))
newly = ring & ~have & (wacc > 0)
C[newly] = acc[newly] / wacc[newly, None]
have |= newly
out_rgb[ring & have] = C[ring & have]
mask_px |= ring & have
log(f"rim absorb: +{(ring & have).sum()} ring texels -> mask {mask_px.sum()}")
def box_blur1(a2, r):
def b1(x, axis):
p = [(0, 0)] * x.ndim
p[axis] = (r, r)
cs = np.cumsum(np.pad(x, p, mode="edge"), axis=axis)
return (np.take(cs, np.arange(2 * r, cs.shape[axis]), axis=axis) -
np.take(cs, np.arange(0, cs.shape[axis] - 2 * r), axis=axis)) / (2 * r)
return b1(b1(a2, 0), 1)
# ---- grain transplant (per-channel high-pass, tile-based) ----
src_ok = ~mask_px
grain = np.stack([out_rgb[:, :, c] - box_blur1(out_rgb[:, :, c], 5) for c in range(3)], axis=2)
# candidate tiles must be clean AND skin-toned (the atlas also holds eyes/lips whose
# high-contrast grain would streak the fill)
skin_tone = np.median(out_rgb[mask_px], axis=0) if mask_px.any() else np.array([0.6, 0.45, 0.38])
cand = []
for ty in range(0, h - GRAIN_T, GRAIN_T):
for tx in range(0, w - GRAIN_T, GRAIN_T):
if not src_ok[ty:ty + GRAIN_T, tx:tx + GRAIN_T].all():
continue
tmean = out_rgb[ty:ty + GRAIN_T, tx:tx + GRAIN_T].reshape(-1, 3).mean(axis=0)
if np.abs(tmean - skin_tone).max() < 0.13:
cand.append((ty, tx))
rng = np.random.RandomState(77)
covered = 0
for ty in range(0, h - GRAIN_T + 1, GRAIN_T):
for tx in range(0, w - GRAIN_T + 1, GRAIN_T):
tm = mask_px[ty:ty + GRAIN_T, tx:tx + GRAIN_T]
if not tm.any():
continue
sy, sx = cand[rng.randint(len(cand))]
blk = out_rgb[ty:ty + GRAIN_T, tx:tx + GRAIN_T]
blk[tm] += grain[sy:sy + GRAIN_T, sx:sx + GRAIN_T][tm] * 0.85
covered += int(tm.sum())
log(f"grain: {covered} texels from {len(cand)} source tiles")
# feather rim
a_ = np.ones((h, w))
edge = mask_px.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(mask_px, 1.0, 1.0 - a_)[:, :, None]
orig = rgb[:, :, :3].astype(np.float64)
final = np.clip(out_rgb * blend + orig * (1 - blend), 0, 1)
buf4 = rgb.copy()
buf4[:, :, :3] = final.astype(np.float32)
base.pixels.foreach_set(buf4.reshape(-1))
base.pack()
log("basecolor updated + packed")
# ---- normal + rm: neutralise over the same texels ----
for key_, flatval in (("normal", None), ("rm", None)):
if key_ not in imgs:
continue
im = imgs[key_]
iw, ih = im.size
b2 = np.empty(iw * ih * 4, dtype=np.float32)
im.pixels.foreach_get(b2)
arr = b2.reshape(ih, iw, 4)
if (iw, ih) != (w, h):
log(f" {key_}: size {iw}x{ih} != base — skipping")
continue
if key_ == "normal":
arr[mask_px, 0] = 0.5
arr[mask_px, 1] = 0.5
arr[mask_px, 2] = 1.0
else:
med = np.median(arr[src_ok][:, :3], axis=0)
arr[mask_px, 0] = med[0]
arr[mask_px, 1] = med[1]
arr[mask_px, 2] = med[2]
im.pixels.foreach_set(arr.reshape(-1))
im.pack()
log(f" {key_}: neutralised {mask_px.sum()} texels + packed")
bpy.context.preferences.filepaths.save_version = 0 # no .blend1 autosave
bpy.ops.wm.save_as_mainfile(filepath=OUT)
log(f"WROTE {OUT}")
print("TEXTURE_DONE")