# Bake the NUDE body albedo: paint the sculpted-in bra AND briefs out of the stock texture, # leaving uniform skin — no tan line, no nipples, no crotch detail. # # blender --background --python tools/_bake_nude_body_texture.py -- [--out ] [--debug-mask] # # WHY BOTH GARMENTS ARE REDONE HERE rather than starting from Ariki_Female_QuatSkin_Bare's # already-bra-free texture: that file's chest fill leaves visible streaks, which show up as # smudges once the chest carries real breast volume. Doing bra and briefs in one pass with one # technique gives a consistent result and drops the dependency on _Bare (whose geometry is # broken anyway — see make_lena_nude_body.py). # # The mask comes from the MESH, not from guessed UV rectangles: faces whose vertices are painted # fabric are rasterised into UV space, which automatically finds every scattered island (torso # front, back straps, shoulder straps, waistband) without anyone hand-listing coordinates. The # atlas mirrors islands left/right, so one fill covers both sides. # # The fill is a harmonic (Jacobi neighbour-average) diffusion from the surrounding skin. That is # chosen for what it CANNOT do: a harmonic function has no interior extrema, so the filled area # is smooth by construction and cannot invent a nipple, an areola, or a crotch seam. import bpy, sys, os, json, struct, argparse from collections import deque import numpy as np # This tool lives in the ANIMATION repo (moved from ariki-game 2026-08-06: it authors characters, # and characters/REGISTRY.md governs its subjects — its output already staged here before the tool # did). The stock body+albedo it reads still live in the game repo, so that path is RESOLVED, # never assumed: $ARIKI_GAME wins, otherwise a sibling checkout is guessed and then verified. An # ariki-relative default that silently fails to resolve is a known trap here — see # .agents/wiki/ARCHITECTURE.md on the retargeters' --target. REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) GAME = os.environ.get("ARIKI_GAME") or os.path.abspath(os.path.join(REPO, os.pardir, "ariki-game")) BODIES = os.path.join(GAME, "assets/quaternius/derived-bodies") SRC_GLB = os.path.join(BODIES, "Ariki_Female_QuatSkin.glb") SRC_TEX = os.path.join(BODIES, "Ariki_Female_QuatSkin_Lena_Body_Toned.png") # Output defaults to THIS repo's staging tree, not next to the source bodies in ariki-game. # characters/REGISTRY.md rule 8 (the boundary law) reserves PascalCase "Ariki_*" for game-repo # ship names and forbids them upstream: the rename happens exactly once, at release. Writing an # Ariki_*_Nude.png next to the canonical bodies would mint a ship name for something still WIP. STAGING = os.path.join(REPO, "characters", "female", "lena_nude") OUT_TEX = os.path.join(STAGING, "lena_nude_basecolor.png") def game_asset(path, flag): """Fail loudly on an unresolved cross-repo path instead of reading the wrong file.""" if not os.path.isfile(path): raise SystemExit( f"[nude-tex] FATAL: cannot find {os.path.basename(path)}\n" f" looked in: {path}\n" f" That is an ariki-game asset. Pass {flag} explicitly, or set ARIKI_GAME\n" f" to your ariki-game checkout (the sibling-directory guess failed).") return path # Colour key, measured per-face on this albedo: garment faces sit at sat 0.354 / R-B 1.55, # plain skin at sat 0.546 / R-B 2.20. The threshold is deliberately on the garment side of that # gap — the rim is recovered by growing a mesh ring afterwards, never by loosening the key. SAT_STRONG, RB_STRONG = 0.42, 1.80 # Second, permissive key used ONLY to reclaim leftover garment texels lying next to the mask # (pale rim, dark stitching). Safe because it is confined to the neighbourhood of the # geometry-anchored mask; applied atlas-wide it would eat skin. SAT_NEAR, RB_NEAR = 0.50, 2.05 # Third key: RELATIVE DARKNESS. The bra's painted cast-shadow band under the bust, and the # garments' stitch lines, are dark and fairly SATURATED, so neither pale-fabric key sees them — # they survived every earlier pass as a dark smudge across the ribcage, and a clay render proved # it was paint and not geometry. Same trick as tools/_bake_browless_face.py: a pixel much darker # than its own neighbourhood is ink, not skin tone. Kept on a short leash (DARK_PX) so ordinary # anatomical shading — the navel, the ab creases — is not erased along with it. # The band is a WIDE soft gradient, so the blur it is compared against has to be wider still — # at radius 15 the band darkened its own reference and the test caught almost nothing (1.3k px). # Its neighbourhood is bounded in MESH rings rather than UV pixels: "just below the bra" is a # statement about the body, and UV distance does not respect it. DARK_REL = 0.90 DARK_BLUR = 70 DARK_RINGS = 6 MESH_RINGS = 2 NEAR_PX = 18 Z_LO, Z_HI = 0.80, 1.42 # torso band; keeps low-saturation eyes/teeth/nails out DILATE_PX = 8 ITERS = 400 FEATHER_PX = 8 COMP_FMT = {5120: "b", 5121: "B", 5122: "h", 5123: "H", 5125: "I", 5126: "f"} NCOMP = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16} def log(m): print(f"[nude-tex] {m}", flush=True) class Gltf: """Minimal GLB reader (pattern from clothing/skirt_garment_weights.py). Used instead of the bpy importer so the triangle list and its per-vertex UVs come through unchanged, with no loop indirection and no stray-Icosphere ambiguity.""" def __init__(self, path): with open(path, "rb") as f: blob = f.read() clen, _ = struct.unpack_from(" best[0]: best = (n, p) return best[1] def load_image(path): img = bpy.data.images.load(path) w, h = img.size buf = np.empty(w * h * 4, dtype=np.float32) img.pixels.foreach_get(buf) # foreach_get: pixels[:] on 4096^2 is glacial return img, buf.reshape(h, w, 4), w, h def tri_pixels(p, w, h): """Indices of the texels strictly inside one UV triangle (pixel coords). Returns (ys, xs). Sampling a triangle's bounding BOX instead — the obvious shortcut — pulls in neighbouring islands, and on this atlas that made the underwear's own texels read as skin.""" x0, x1 = int(np.floor(p[:, 0].min())), int(np.ceil(p[:, 0].max())) y0, y1 = int(np.floor(p[:, 1].min())), int(np.ceil(p[:, 1].max())) if x1 - x0 > 512 or y1 - y0 > 512: return None, None # straddles the atlas edge x0, y0 = max(x0, 0), max(y0, 0) x1, y1 = min(x1, w - 1), min(y1, h - 1) if x1 < x0 or y1 < y0: return None, None gx, gy = np.meshgrid(np.arange(x0, x1 + 1), np.arange(y0, y1 + 1)) 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: return None, None a = ((p[1, 1] - p[2, 1]) * (gx - p[2, 0]) + (p[2, 0] - p[1, 0]) * (gy - p[2, 1])) / d bb = ((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 - bb inside = (a >= -0.002) & (bb >= -0.002) & (c >= -0.002) if not inside.any(): return None, None return gy[inside], gx[inside] def uv_px(uv, w, h): """glTF UV -> Blender pixel coords. THE V FLIP IS LOAD-BEARING. glTF puts the texture origin at the UPPER-left with v running down; Blender's image buffer starts at the LOWER-left. UVs read straight out of the glTF are therefore mirrored against a Blender-loaded image. Skipping this flip silently sampled the wrong half of the atlas: the briefs measured as skin (sat 0.56) and plain belly skin measured as fabric, so the mask formed in the wrong places and the fill smudged the chest. (tools/make_lena_nude_body.py does NOT need this — it reads UVs from the bpy importer, which has already flipped them.)""" return np.stack([np.clip(uv[:, 0], 0, 1) * (w - 1), (1.0 - np.clip(uv[:, 1], 0, 1)) * (h - 1)], axis=1) def face_fabric(uv, tris, sel, rgb, w, h): """Classify each selected face by the MEAN colour of the texels inside its own UV triangle. Per-face means are far steadier than the per-vertex sample this started as: a vertex sits on an island boundary where the texel is often skin or padding, which under-selected the garment by roughly 3x.""" PU = uv_px(uv, w, h) idx = np.nonzero(sel)[0] fab = np.zeros(len(tris), dtype=bool) sats = np.zeros(len(tris)) rbs = np.zeros(len(tris)) got = np.zeros(len(tris), dtype=bool) for fi in idx: ys, xs = tri_pixels(PU[tris[fi]], w, h) if ys is None: continue m = rgb[ys, xs].mean(axis=0) mx, mn = m.max(), m.min() sat = (mx - mn) / mx if mx > 1e-5 else 0.0 rb = m[0] / max(m[2], 1e-5) sats[fi], rbs[fi], got[fi] = sat, rb, True # STRICT threshold on purpose. The permissive pair (sat<0.55, R/B<2.20) sits exactly on # ordinary belly skin's median (0.546 / 2.20) and flagged half of it; the strict pair # separates cleanly — garment faces measure 0.354 / 1.55, skin 0.546 / 2.20. The rim is # recovered afterwards by growing one mesh ring, not by loosening this. fab[fi] = (sat < SAT_STRONG) and (rb < RB_STRONG) return fab, sats, rbs, got def rasterize_faces(uv, tris, keep, w, h): """Fill the UV triangles of the kept faces into a boolean image, barycentrically. Blender image rows run bottom-up and so does UV v, so no flip is needed here.""" mask = np.zeros((h, w), dtype=bool) px = uv_px(uv, w, h) for t in tris[keep]: p = px[t] x0, x1 = int(np.floor(p[:, 0].min())), int(np.ceil(p[:, 0].max())) y0, y1 = int(np.floor(p[:, 1].min())), int(np.ceil(p[:, 1].max())) if x1 < x0 or y1 < y0: continue x1, y1 = min(x1, w - 1), min(y1, h - 1) x0, y0 = max(x0, 0), max(y0, 0) if (x1 - x0) > 512 or (y1 - y0) > 512: continue # a face straddling the atlas edge: skip xs = np.arange(x0, x1 + 1) ys = np.arange(y0, y1 + 1) gx, gy = np.meshgrid(xs, ys) 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 a = ((p[1, 1] - p[2, 1]) * (gx - p[2, 0]) + (p[2, 0] - p[1, 0]) * (gy - p[2, 1])) / d bb = ((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 - bb inside = (a >= -0.002) & (bb >= -0.002) & (c >= -0.002) mask[y0:y1 + 1, x0:x1 + 1] |= inside return mask def dilate(m, n): out = m.copy() for _ in range(n): out[1:-1, 1:-1] |= (out[:-2, 1:-1] | out[2:, 1:-1] | out[1:-1, :-2] | out[1:-1, 2:]) return out def components(mask, min_px): keep = np.zeros_like(mask) seen = np.zeros_like(mask) ys, xs = np.nonzero(mask) boxes = [] for sy, sx in zip(ys, xs): if seen[sy, sx]: continue q = deque([(sy, sx)]); seen[sy, sx] = True; comp = [] while q: cy, cx = q.popleft(); comp.append((cy, cx)) for dy, dx in ((-1, 0), (1, 0), (0, -1), (0, 1)): ny, nx = cy + dy, cx + dx if 0 <= ny < mask.shape[0] and 0 <= nx < mask.shape[1] \ and mask[ny, nx] and not seen[ny, nx]: seen[ny, nx] = True; q.append((ny, nx)) if len(comp) >= min_px: ca = np.array(comp) for cy, cx in comp: keep[cy, cx] = True boxes.append((ca[:, 0].min(), ca[:, 0].max(), ca[:, 1].min(), ca[:, 1].max())) return keep, boxes def _pool2(a): """2x2 sum-pool, cropping any odd row/column.""" h, w = a.shape[0] // 2 * 2, a.shape[1] // 2 * 2 a = a[:h, :w] if a.ndim == 3: return a.reshape(h // 2, 2, w // 2, 2, a.shape[2]).sum(axis=(1, 3)) return a.reshape(h // 2, 2, w // 2, 2).sum(axis=(1, 3)) def box_blur(a, r): """Separable box blur via cumulative sums (pattern from tools/_bake_browless_face.py). Multi-channel arrays are blurred PER CHANNEL. The 2D original finished with `b1(b1(a,0).T,0).T`, and on an (h,w,3) array that transpose rotates the CHANNEL axis into the blur: "blurred" R became roughly mean(R,G,B), so grain = a - blur turned into a saturation booster (+red, -blue) instead of mean-zero texture — every fill region baked out Fanta-orange, brighter than any actual skin texel in the atlas.""" if a.ndim == 3: return np.stack([box_blur(a[:, :, c], r) for c in range(a.shape[2])], axis=2) def b1(x, axis): p = [(0, 0)] * x.ndim p[axis] = (r, r) c = np.cumsum(np.pad(x, p, mode="edge"), axis=axis) return (np.take(c, np.arange(2 * r, c.shape[axis]), axis=axis) - np.take(c, np.arange(0, c.shape[axis] - 2 * r), axis=axis)) / (2 * r) return b1(b1(a, 0).T, 0).T def box3(a): """3x3 box blur, edge-clamped — used to take the blockiness out of an upsampled level.""" p = np.pad(a, ((1, 1), (1, 1), (0, 0)), mode="edge") return (p[:-2, :-2] + p[:-2, 1:-1] + p[:-2, 2:] + p[1:-1, :-2] + p[1:-1, 1:-1] + p[1:-1, 2:] + p[2:, :-2] + p[2:, 1:-1] + p[2:, 2:]) / 9.0 def pull_push(sub, valid, fallback): """Multi-scale 'pull-push' hole fill: sum-pool colour and coverage down an image pyramid, then walk back up letting each level inherit its holes from the coarser one. This replaces a Jacobi/harmonic relaxation, which was the original approach and was WRONG at this scale: Jacobi propagates information one pixel per iteration, so a hole ~600 px across needs on the order of 600^2 sweeps to converge. At 400 sweeps the interior never heard from the boundary and simply kept its seed value — the mean of a ring that still included pale fabric — which is exactly why the briefs showed up as a lighter panty-shaped patch. The pyramid carries boundary information across the whole hole in log(width) steps instead. `valid` marks the pixels allowed to act as SOURCES, which is deliberately narrower than "not masked": the atlas packs islands close together with pale padding in the gutters, so pooling every unmasked pixel in the bounding box averaged in gutter and neighbouring-island texels and produced a pale, desaturated patch shaped like the briefs. Only verified torso skin is allowed to contribute. Still smooth by construction, so it cannot invent a nipple or a crotch seam.""" vf = valid.astype(np.float64) cols = [sub.astype(np.float64) * vf[:, :, None]] wts = [vf] while min(wts[-1].shape[:2]) > 4: cols.append(_pool2(cols[-1])) wts.append(_pool2(wts[-1])) # Coarsest level: any cell with NO coverage falls back to the reference skin tone. Without # this the cell stays at zero and the pyramid carries pure black upward — which showed up as # big black rectangles over the hips. Uncovered coarse cells are not hypothetical: some UV # islands are almost entirely garment, so their bbox holds no skin to interpolate from at all. w = wts[-1] sol = np.where(w[:, :, None] > 0, cols[-1] / np.maximum(w, 1e-9)[:, :, None], fallback[None, None, :]) for lv in range(len(cols) - 2, -1, -1): up = np.repeat(np.repeat(sol, 2, axis=0), 2, axis=1) h, wd = cols[lv].shape[:2] if up.shape[0] < h or up.shape[1] < wd: # odd dims were cropped on the way down up = np.pad(up, ((0, max(0, h - up.shape[0])), (0, max(0, wd - up.shape[1])), (0, 0)), mode="edge") up = box3(up[:h, :wd]) cw = wts[lv] own = np.where(cw[:, :, None] > 0, cols[lv] / np.maximum(cw, 1e-9)[:, :, None], up) sol = np.where(cw[:, :, None] > 0, own, up) return sol def diffuse_fill(rgb, mask, boxes, fallback, source, target=None): """Fill each masked island from verified surrounding skin — seamlessly, and with the skin's own grain. Three stages beyond the raw pull-push, each answering a specific observed failure: 1. Jacobi polish — removes the pyramid's blockiness (convergence not needed; pull_push already supplied the low frequencies). 2. SEAMLESS OFFSET (a cheap Poisson-style clone): the residual (original - fill) is measured on a ring of real skin just outside the mask and smoothly extended over the fill via a second pull_push. The fill then matches the surrounding skin exactly at the boundary, killing the tonal seam an alpha feather merely narrowed — the ghost detector still measured the old garment outline at 1.28x control on grazing views with feather alone. 3. GRAIN REINJECTION: the fill is smooth by construction, but real skin here carries high-frequency paint grain, so a smooth patch reads as a decal in exactly the shape of the removed garment. The grain is harvested from the surrounding skin itself (residual vs a box blur, sampled at fixed shifted offsets so no synthetic pattern is invented) and added back inside the mask.""" out = rgb.copy() for (y0, y1, x0, x1) in boxes: pad = DILATE_PX + FEATHER_PX + 40 # reach far enough to find real skin to pool from ya, yb = max(0, y0 - pad), min(rgb.shape[0], y1 + pad + 1) xa, xb = max(0, x0 - pad), min(rgb.shape[1], x1 + pad + 1) sub = out[ya:yb, xa:xb, :3] m = mask[ya:yb, xa:xb] if not m.any(): continue src = source[ya:yb, xa:xb] if target is not None: # 3D-aware low-frequency base; pull-push only backfills texels the target missed tgt = target[ya:yb, xa:xb] have = ~np.isnan(tgt[:, :, 0]) filled = pull_push(sub, src, fallback) filled = np.where(have[:, :, None], np.nan_to_num(tgt), filled) else: filled = pull_push(sub, src, fallback) cur = np.where(m[:, :, None], filled, sub.astype(np.float64)) # With a 3D target the relaxation must stay LIGHT: hundreds of passes would diffuse the # atlas-boundary tone back over the target and undo the 3D awareness. iters = 12 if target is not None else ITERS for _ in range(iters): nb = np.zeros_like(cur) nb[1:-1] += cur[:-2] + cur[2:] nb[:, 1:-1] += cur[:, :-2] + cur[:, 2:] cnt = np.zeros(cur.shape[:2]) cnt[1:-1] += 2 cnt[:, 1:-1] += 2 cur[m] = (nb / np.maximum(cnt, 1)[:, :, None])[m] # seamless offset: harmonic-extend the boundary residual over the fill ring = dilate(m, 3) & ~m & src if ring.any(): resid = np.zeros_like(cur) resid[ring] = sub[ring].astype(np.float64) - cur[ring] ext = pull_push(resid, ring, np.zeros(3)) cur[m] += ext[m] # Grain reinjection, harvested from surrounding real skin. Tile-transplanted: each 16 px # tile of the fill copies the high-pass residual of a randomly chosen all-skin tile. # (A first attempt sampled at three fixed pixel shifts instead; on large islands the # shifted positions land inside the mask itself and coverage fell to 0-30%, leaving # smooth patches in exactly the garment's shape — the thing grain exists to prevent.) T = 16 grain = sub.astype(np.float64) - box_blur(sub.astype(np.float64), 5) h_, w_ = m.shape cand = [] for ty in range(0, h_ - T, T): for tx in range(0, w_ - T, T): if src[ty:ty + T, tx:tx + T].all(): cand.append((ty, tx)) covered = 0 if cand: rng = np.random.RandomState(1234) # fixed seed: deterministic bake for ty in range(0, h_ - T + 1, T): for tx in range(0, w_ - T + 1, T): tm = m[ty:ty + T, tx:tx + T] if not tm.any(): continue sy, sx = cand[rng.randint(len(cand))] g = grain[sy:sy + T, sx:sx + T] blk = cur[ty:ty + T, tx:tx + T] blk[tm] += g[tm] * 0.85 covered += int(tm.sum()) log(f" island: grain tiles from {len(cand)} skin tiles -> {covered}/{int(m.sum())} px, " f"seam ring {int(ring.sum())} px") # narrow feather as belt-and-braces on top of the seamless offset a = np.ones(m.shape) edge = m.copy() for k in range(FEATHER_PX): nxt = dilate(edge, 1) & ~m a[nxt] = (k + 1) / (FEATHER_PX + 1.0) edge = edge | nxt blend = np.where(m, 1.0, 1.0 - a)[:, :, None] out[ya:yb, xa:xb, :3] = np.clip(cur * blend + sub * (1 - blend), 0, 1).astype(np.float32) return out def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] ap = argparse.ArgumentParser() ap.add_argument("--glb", default=SRC_GLB) ap.add_argument("--tex", default=SRC_TEX) ap.add_argument("--out", default=OUT_TEX) ap.add_argument("--debug-mask", default="") a = ap.parse_args(argv) out_dir = os.path.dirname(os.path.abspath(a.out)) if not os.path.isdir(out_dir): raise SystemExit(f"[nude-tex] FATAL: output directory does not exist: {out_dir}\n" f" pass --out explicitly, or create the staging folder " f"(see characters/REGISTRY.md)") game_asset(a.glb, "--glb") game_asset(a.tex, "--tex") g = Gltf(a.glb) prim = g.body_prim() pos = g.read(prim["attributes"]["POSITION"]) uv = g.read(prim["attributes"]["TEXCOORD_0"]) tris = g.read(prim["indices"]).reshape(-1, 3) log(f"{os.path.basename(a.glb)}: {len(pos)} verts, {len(tris)} tris") img, px, w, h = load_image(a.tex) log(f"{os.path.basename(a.tex)}: {w}x{h} uv u[{uv[:,0].min():.3f},{uv[:,0].max():.3f}] " f"v[{uv[:,1].min():.3f},{uv[:,1].max():.3f}]") rgb = px[:, :, :3] # Geometry decides only WHICH FACES to consider — the torso band. This keeps the pale atlas # gutters out (no face covers them) and the low-saturation eyes/teeth/nails out (wrong # height). glTF is Y-up, so height is component 1. cen = pos[tris].mean(axis=1) torso = (cen[:, 1] > Z_LO) & (cen[:, 1] < Z_HI) torso_region = rasterize_faces(uv, tris, torso, w, h) log(f"torso faces: {torso.sum()} of {len(tris)} -> {torso_region.sum()} px") fab, sats, rbs, got = face_fabric(uv, tris, torso, rgb, w, h) log(f"fabric faces: {fab.sum()} of {got.sum()} classified") # Validate the classifier against regions whose identity is known from anatomy alone, so a # bad threshold shows up as a number here instead of as a smudge in the final render. for nm, sel in ( ("briefs front", (cen[:, 1] > 0.90) & (cen[:, 1] < 1.00) & (np.abs(cen[:, 0]) < 0.10) & (cen[:, 2] > 0.04)), ("bra cups ", (cen[:, 1] > 1.22) & (cen[:, 1] < 1.32) & (np.abs(cen[:, 0]) < 0.12) & (cen[:, 2] > 0.05)), ("belly SKIN ", (cen[:, 1] > 1.05) & (cen[:, 1] < 1.11) & (np.abs(cen[:, 0]) < 0.07) & (cen[:, 2] > 0.03)), ("thigh SKIN ", (cen[:, 1] > 0.68) & (cen[:, 1] < 0.76) & (np.abs(cen[:, 0]) < 0.12)), ): s = sel & got if s.sum(): log(f" [{nm}] n={s.sum():4d} flagged={100*fab[s].mean():5.1f}% " f"sat med={np.median(sats[s]):.3f} R/B med={np.median(rbs[s]):.2f}") # Grow over the rim faces. Only 59-74% of garment faces pass the strict key — the misses are # edge faces whose texel average is half skin — so the leftovers sit exactly ON the mask # boundary, and a harmonic fill would then interpolate FROM pale fabric and leave a lighter # ghost of the briefs plus dark stitch dashes. Growing outward puts the boundary on real skin. def grow_rings(sel, n): out = sel.copy() for _ in range(n): vg = np.zeros(len(pos), dtype=bool) vg[tris[out].ravel()] = True out = out | (vg[tris].any(axis=1) & torso) return out grown = grow_rings(fab, MESH_RINGS) log(f"after {MESH_RINGS} mesh rings: {grown.sum()} faces") raster = rasterize_faces(uv, tris, grown, w, h) log(f"rasterised: {raster.sum()} px") near_region = rasterize_faces(uv, tris, grow_rings(fab, DARK_RINGS), w, h) log(f"near-garment region ({DARK_RINGS} rings): {near_region.sum()} px") r, b = rgb[:, :, 0], rgb[:, :, 2] mx, mn = rgb.max(axis=2), rgb.min(axis=2) sat = np.where(mx > 1e-5, (mx - mn) / np.maximum(mx, 1e-5), 0.0) near_fab = (sat < SAT_NEAR) & (r / np.maximum(b, 1e-5) < RB_NEAR) mask = raster | (dilate(raster, NEAR_PX) & near_fab) log(f"+ reclaimed pale neighbours: {mask.sum()} px") luma = 0.2126 * rgb[:, :, 0] + 0.7152 * rgb[:, :, 1] + 0.0722 * rgb[:, :, 2] dark = luma < DARK_REL * box_blur(luma, DARK_BLUR) mask = mask | (dark & near_region) log(f"+ painted shadow/stitching: {mask.sum()} px") mask = dilate(mask, DILATE_PX) mask, boxes = components(mask, 200) log(f"final mask: {mask.sum()} px in {len(boxes)} islands") if a.debug_mask: dbg = px.copy() dbg[:, :, 0] = np.where(mask, 1.0, dbg[:, :, 0]) dbg[:, :, 1] = np.where(mask, 0.0, dbg[:, :, 1]) dbg[:, :, 2] = np.where(mask, 0.0, dbg[:, :, 2]) save(dbg, w, h, a.debug_mask) # Fill sources: verified torso skin only — inside the torso's own UV islands, not masked, # not keyed as fabric even permissively, and not painted ink. source = torso_region & ~mask & ~near_fab & ~dark fallback = rgb[source].mean(axis=0).astype(np.float64) if source.sum() > 1000 \ else np.array([0.5, 0.35, 0.28]) log(f"skin sources: {source.sum()} texels, mean " f"rgb({fallback[0]:.3f},{fallback[1]:.3f},{fallback[2]:.3f})") # 3D-aware fill target (the load-bearing stage — see fill_target_3d) target = fill_target_3d(pos, uv, tris, torso, mask, source, rgb, w, h) out = diffuse_fill(px, mask, boxes, fallback, source, target) save(out, w, h, a.out) def fill_target_3d(pos, uv, tris, torso, mask, source, rgb, w, h): """Low-frequency fill colour per masked texel, decided by BODY proximity, not atlas proximity. Why: filling each UV island from its own atlas surroundings gave every island its own tone — the big torso fills converged toward the global mean (an orange noticeably more saturated than the pale flank skin), and islands that sit next to each other ON THE BODY but far apart IN THE ATLAS met at visible tone seams. The atlas cannot see 3D adjacency; this stage can. Method: per-VERTEX, so it is cheap and cross-seam consistent by construction. Every vertex gets its albedo from its own texel; garment vertices then take an inverse-square-distance blend of the K nearest verified-skin vertices in 3D; the per-vertex colours are rasterised barycentrically into the mask. Grain and the seam offset go on top in diffuse_fill.""" PU = uv_px(uv, w, h) xi = np.clip(PU[:, 0], 0, w - 1).astype(int) yi = np.clip(PU[:, 1], 0, h - 1).astype(int) vcol = rgb[yi, xi].astype(np.float64) v_masked = mask[yi, xi] v_source = source[yi, xi] skin_v = np.nonzero(v_source & ~v_masked)[0] need_v = np.nonzero(v_masked)[0] log(f"3d fill: {len(need_v)} garment verts <- {len(skin_v)} skin verts") sp = pos[skin_v] sc = vcol[skin_v] K = 12 fill_c = vcol.copy() for vi in need_v: d2 = np.sum((sp - pos[vi]) ** 2, axis=1) near = np.argpartition(d2, K)[:K] wgt = 1.0 / np.maximum(d2[near], 1e-6) fill_c[vi] = (sc[near] * wgt[:, None]).sum(axis=0) / wgt.sum() # rasterise per-vertex fill colours over the masked faces (one extra ring so the mask's # pixel dilation stays covered), barycentric target = np.full((h, w, 3), np.nan) vg = np.zeros(len(pos), dtype=bool) vg[need_v] = True faces = (vg[tris].any(axis=1)) & torso for fi in np.nonzero(faces)[0]: t = tris[fi] p = PU[t] ys, xs = tri_pixels(p, w, h) if ys is None: continue 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 a_ = ((p[1, 1] - p[2, 1]) * (xs - p[2, 0]) + (p[2, 0] - p[1, 0]) * (ys - p[2, 1])) / d b_ = ((p[2, 1] - p[0, 1]) * (xs - p[2, 0]) + (p[0, 0] - p[2, 0]) * (ys - p[2, 1])) / d c_ = 1.0 - a_ - b_ target[ys, xs] = (a_[:, None] * fill_c[t[0]] + b_[:, None] * fill_c[t[1]] + c_[:, None] * fill_c[t[2]]) # the mask is dilated a few px past the rasterised faces; creep the target outward to cover have = ~np.isnan(target[:, :, 0]) for _ in range(DILATE_PX + 2): grown_have = dilate(have, 1) ring_ = grown_have & ~have ys, xs = np.nonzero(ring_) for oy, ox in ((0, 1), (0, -1), (1, 0), (-1, 0)): ny, nx = np.clip(ys + oy, 0, h - 1), np.clip(xs + ox, 0, w - 1) ok = have[ny, nx] & np.isnan(target[ys, xs, 0]) target[ys[ok], xs[ok]] = target[ny[ok], nx[ok]] have = grown_have cov = (~np.isnan(target[:, :, 0]) & mask).sum() log(f"3d fill: target covers {cov}/{mask.sum()} masked px") return target def save(arr, w, h, path): im = bpy.data.images.new("out", width=w, height=h, alpha=True) im.pixels.foreach_set(arr.reshape(-1).astype(np.float32)) im.filepath_raw = path im.file_format = 'PNG' im.save() log(f"WROTE {path} ({os.path.getsize(path)/1e6:.2f} MB)") main()