3ba86b2ea8
Bulk import of the working lanes that were living untracked on the PC. Content: - characters/ Lena/male body lanes, bakes, texture work, run logs - clothing/ garment pipeline, configs, gates, contract docs - garments/ MD-authored garment sources (.zprj/.zpac) - UAL-Lib/ Universal Animation Library 2 source (.blend/.fbx/.glb) - tools/ blender_bridge, iclone_bridge, md_bridge, tailor, glm_agent - docs/, plans/, dev/, .agents/plans/ Repo hygiene: - .gitattributes: LFS now covers .blend, .zprj, .zpac, .obj, .npy and the Reallusion .iAvatar/.ccAvatar/.ccRestore containers. Without this the ~3.8 GB in this commit would land as raw blobs. .png/.jpg are left out on purpose — ~250 are already tracked raw and converting them would rewrite every one without shrinking history. - .gitignore: exclude /accurig/ (~1 GB AccuRig program files, redistributable from Reallusion, nothing authored here) and /dev/null/ (git-lfs hook copies dropped by a `>/dev/null` redirect on Windows). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
642 lines
30 KiB
Python
642 lines
30 KiB
Python
# 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 <png>] [--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("<II", blob, 12)
|
|
self.d = json.loads(blob[20:20 + clen])
|
|
blen, _ = struct.unpack_from("<II", blob, 20 + clen)
|
|
self.buf = blob[20 + clen + 8: 20 + clen + 8 + blen]
|
|
|
|
def read(self, idx):
|
|
a = self.d["accessors"][idx]
|
|
bv = self.d["bufferViews"][a["bufferView"]]
|
|
off = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
|
|
fmt = COMP_FMT[a["componentType"]]
|
|
n = NCOMP[a["type"]]
|
|
size = struct.calcsize("<" + fmt * n)
|
|
stride = bv.get("byteStride") or size
|
|
if stride == size:
|
|
arr = np.frombuffer(self.buf, dtype=np.dtype(fmt), count=a["count"] * n, offset=off)
|
|
return arr.reshape(a["count"], n).astype(np.float64 if fmt == "f" else np.int64)
|
|
return np.array([struct.unpack_from("<" + fmt * n, self.buf, off + i * stride)
|
|
for i in range(a["count"])])
|
|
|
|
def body_prim(self):
|
|
"""The primitive with the most vertices — i.e. the body, not the stray Icosphere."""
|
|
best = None
|
|
for m in self.d["meshes"]:
|
|
for p in m["primitives"]:
|
|
n = self.d["accessors"][p["attributes"]["POSITION"]]["count"]
|
|
if best is None or n > 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()
|