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,397 @@
|
||||
# Stage 42: add muscle definition — abs, obliques, quads, calves, delts, biceps/triceps — into the
|
||||
# MAPS, not the mesh. Geometry is untouched, so this is fully reversible and costs no vertices.
|
||||
#
|
||||
# blender --background --python 42_musculature.py -- <in.blend> <out.blend> [out.glb] [strength]
|
||||
#
|
||||
# WHY THE NORMAL MAP AND NOT PAINTED SHADING. Darkening the albedo to suggest a muscle bakes one
|
||||
# lighting direction into the skin: it reads as dirt the moment the key light moves, and it is
|
||||
# wrong in every pose. A normal-map perturbation is relief — it lights correctly from any angle.
|
||||
# The albedo gets only a faint cavity term (valleys slightly darker), which is what subsurface
|
||||
# actually does and which survives relighting.
|
||||
#
|
||||
# WHY THIS WORKS CLEANLY IN THE NEW ATLAS. A tangent-space normal is the surface gradient
|
||||
# expressed in (u,v). Take the gradient of a height field in atlas space and you have exactly
|
||||
# that — but only if texel density is uniform, otherwise the same slope means different things in
|
||||
# different charts. The 14-chart atlas measures 1.00x spread, so one global scale is correct
|
||||
# everywhere. On Tripo's 1.8x-spread soup this would have needed per-chart correction.
|
||||
#
|
||||
# The anatomy is built as a per-vertex height field in BODY-FRAME coordinates (height fraction,
|
||||
# left-right, front-back), so it lands on the right muscles regardless of mesh density, and is
|
||||
# rasterised through the same barycentric path every other stage here uses.
|
||||
#
|
||||
# EXCLUDED ON PURPOSE: the breast region and the crotch. Those were re-authored featureless to a
|
||||
# brief; muscle relief must not put detail back into them.
|
||||
import bpy, sys, os, time
|
||||
import numpy as np
|
||||
|
||||
argv = sys.argv[sys.argv.index("--") + 1:]
|
||||
BLEND, OUT = argv[0], argv[1]
|
||||
GLB = next((a for a in argv[2:] if a.lower().endswith(".glb")), "")
|
||||
STRENGTH = float(next((a for a in argv[2:] if not a.lower().endswith(".glb")), "1.0"))
|
||||
t0 = time.time()
|
||||
SPINE = float(os.environ.get('SPINE', '0.34'))
|
||||
V08_EXTRAS = False # v08's added anatomy was rejected; v07's set is the base
|
||||
AMP_MM = 3.2 # peak relief of the muscle field, in millimetres
|
||||
CAVITY = 0.055 # how much the albedo darkens in the valleys (0 = none)
|
||||
|
||||
|
||||
def log(m):
|
||||
print(f"[mus {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
|
||||
n_v, n_l, n_f = len(me.vertices), len(me.loops), len(me.polygons)
|
||||
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
|
||||
lo, hi = co.min(axis=0), co.max(axis=0)
|
||||
span = hi - lo
|
||||
UNITM = 1.777 / span[2]
|
||||
MM = UNITM * 1000.0
|
||||
u = (co[:, 2] - lo[2]) / span[2] # 0 feet .. 1 head
|
||||
x = co[:, 0] - 0.5 * (lo[0] + hi[0]) # left(+) / right(-)
|
||||
y = co[:, 1] - 0.5 * (lo[1] + hi[1]) # front is NEGATIVE
|
||||
HALF = 0.5 * span[0]
|
||||
sn = x / HALF
|
||||
front = y < 0
|
||||
log(f"{n_v}v 1 unit = {MM:.1f} mm half-span {HALF:.4f}")
|
||||
|
||||
|
||||
def bump(v, c, s):
|
||||
return np.exp(-0.5 * ((v - c) / s) ** 2)
|
||||
|
||||
|
||||
def sstep(t):
|
||||
t = np.clip(t, 0.0, 1.0)
|
||||
return t * t * (3.0 - 2.0 * t)
|
||||
|
||||
|
||||
def band(v, a, b, soft):
|
||||
"""Smoothstep window, NOT a linear ramp.
|
||||
|
||||
A normal map is built from the DERIVATIVE of the height field, so any kink in the field draws
|
||||
a visible line. A linear clip ramp has a derivative that jumps at both ends of the window, and
|
||||
that is exactly what put contour lines down her thighs and shins in the first pass. Smoothstep
|
||||
reaches each end with zero slope, so windows fade out invisibly.
|
||||
"""
|
||||
return sstep((v - a) / soft) * sstep((b - v) / soft)
|
||||
|
||||
|
||||
def ridge(v, c, s):
|
||||
"""a soft-shouldered ridge/groove profile, also C1-continuous"""
|
||||
return np.exp(-0.5 * ((v - c) / s) ** 2)
|
||||
|
||||
|
||||
# The field is evaluated PER TEXEL, not per vertex. Building it on vertices and interpolating
|
||||
# barycentrically was the first attempt and it produced crumpled-paper noise: the mesh has ~10 mm
|
||||
# edges, an ab block is 2-3 vertices across, and interpolating a per-vertex field makes its
|
||||
# gradient piecewise-constant per triangle — so the normal map followed the TRIANGULATION rather
|
||||
# than the anatomy. Rasterising position and evaluating the anatomy at texel resolution fixes it
|
||||
# at the source.
|
||||
def muscle_field(u, x, y, sn):
|
||||
front = y < 0.0
|
||||
# Smooth front/back blend, NOT a boolean. `y < 0` terminates every front- or back-only field
|
||||
# abruptly at the silhouette, and since the normal map is the derivative of the field, that
|
||||
# step drew a line straight down the side of each limb — the sickle marks on her shins.
|
||||
fr = sstep((0.024 - y) / 0.048)
|
||||
h = np.zeros_like(u)
|
||||
|
||||
# rectus abdominis — heights measured off this mesh's front-midline profile
|
||||
NAVEL = 0.560
|
||||
torso_f = fr * band(np.abs(sn), -1.0, 0.35, 0.07)
|
||||
ab_win = torso_f * band(u, 0.470, 0.668, 0.045) * band(np.abs(x), 0.0, 0.064, 0.022)
|
||||
col = bump(np.abs(x), 0.027, 0.015)
|
||||
linea = -1.30 * bump(x, 0.0, 0.0055)
|
||||
rows = np.ones_like(u)
|
||||
for uc in (0.505, 0.600, 0.634):
|
||||
rows -= 1.15 * bump(u, uc, 0.0065)
|
||||
rows -= 1.35 * bump(u, NAVEL, 0.0085)
|
||||
h += 1.00 * ab_win * (col * np.clip(rows, -1.5, 1.0) + linea)
|
||||
|
||||
# obliques
|
||||
ob_win = torso_f * band(u, 0.480, 0.648, 0.050) * band(np.abs(x), 0.056, 0.100, 0.020)
|
||||
h += 0.60 * ob_win * bump(np.abs(x) - 0.18 * (u - 0.48), 0.072, 0.018)
|
||||
|
||||
# serratus
|
||||
se_win = torso_f * band(u, 0.628, 0.700, 0.035) * band(np.abs(x), 0.050, 0.095, 0.020)
|
||||
h += 0.32 * se_win * np.cos((np.abs(x) * 26.0 + u * 34.0) * np.pi)
|
||||
|
||||
# leg centre line, MEASURED (24_seams.py) — used by quads, hamstrings, calves and knee
|
||||
# quadriceps — centred on the leg's MEASURED centre line, not a guess. Her stance has the
|
||||
# feet apart, so each leg's centre moves OUTWARD going down (x 0.037 at the hip to 0.106 at
|
||||
# the ankle). An earlier version had it drifting the other way, which laid the muscle bellies
|
||||
# along the edges of the thigh and left contour lines down the leg.
|
||||
legc = np.interp(u, [0.08, 0.17, 0.25, 0.33, 0.41],
|
||||
[0.1064, 0.0921, 0.0911, 0.0671, 0.0375])
|
||||
ctr = np.abs(x) - legc
|
||||
q_win = fr * band(u, 0.185, 0.400, 0.075)
|
||||
h += 0.62 * q_win * (bump(ctr, -0.030, 0.022) + 0.85 * bump(ctr, 0.028, 0.022)
|
||||
- 0.70 * bump(ctr, 0.0, 0.014))
|
||||
|
||||
# calves, on the back of the lower leg
|
||||
c_win = (1.0 - fr) * band(u, 0.095, 0.225, 0.055)
|
||||
h += 0.55 * c_win * (bump(ctr, -0.024, 0.019) + 0.80 * bump(ctr, 0.022, 0.019))
|
||||
|
||||
# ---- back: without these the whole rear reads as a blank, which is what "smooth mannequin"
|
||||
# looked like. Spinal channel, lats sweeping down to the waist, trapezius over the shoulders.
|
||||
bk = (1.0 - fr) * band(np.abs(sn), -1.0, 0.35, 0.07)
|
||||
# a real spinal furrow fades out at the sacrum; running it to 0.470 pushed it down
|
||||
# into the glute cleft, where it met the crease already in the mesh and read ragged
|
||||
h -= SPINE * bk * band(u, 0.520, 0.795, 0.075) * bump(x, 0.0, 0.019) # spinal furrow
|
||||
lat = bk * band(u, 0.560, 0.760, 0.055) * band(np.abs(x), 0.030, 0.105, 0.022)
|
||||
h += 0.52 * lat * bump(np.abs(x) - 0.10 * (0.76 - u), 0.062, 0.024) # latissimus
|
||||
h += 0.40 * bk * band(u, 0.720, 0.815, 0.035) * band(np.abs(x), 0.0, 0.090, 0.030) # traps
|
||||
|
||||
# deltoids
|
||||
h += 0.50 * band(np.abs(sn), 0.30, 0.46, 0.06) * band(u, 0.695, 0.805, 0.040)
|
||||
|
||||
# biceps / triceps
|
||||
h += 0.42 * band(np.abs(sn), 0.40, 0.62, 0.08) * np.where(front, 1.0, 0.85) \
|
||||
* bump(u, 0.715, 0.026)
|
||||
|
||||
if V08_EXTRAS:
|
||||
# ---- clavicles: two ridges sweeping out from the sternal notch --------------------
|
||||
cl = fr * band(u, 0.780, 0.822, 0.016) * band(np.abs(x), 0.008, 0.105, 0.022)
|
||||
h += 0.55 * cl * ridge(u - 0.797 - 0.055 * np.abs(x), 0.0, 0.0075)
|
||||
|
||||
# ---- lower rib arch: the inverted V under the sternum -----------------------------
|
||||
ra = fr * band(u, 0.630, 0.690, 0.022) * band(np.abs(x), 0.010, 0.075, 0.020)
|
||||
h -= 0.40 * ra * ridge(u - 0.688 + 0.72 * np.abs(x), 0.0, 0.0070)
|
||||
|
||||
# ---- inguinal furrow: the "V" from the hip points down toward the pubis ------------
|
||||
ing = fr * band(u, 0.425, 0.520, 0.030) * band(np.abs(x), 0.012, 0.090, 0.022)
|
||||
h -= 0.85 * ing * ridge(u - 0.428 - 0.92 * np.abs(x), 0.0, 0.0085)
|
||||
|
||||
# ---- glutes: two masses, a central cleft, and the fold under them ------------------
|
||||
gl = (1.0 - fr) * band(u, 0.405, 0.530, 0.035) * band(np.abs(x), 0.0, 0.125, 0.030)
|
||||
h += 0.60 * gl * ridge(np.abs(x), 0.062, 0.038)
|
||||
h -= 0.55 * (1.0 - fr) * band(u, 0.400, 0.545, 0.040) * ridge(x, 0.0, 0.011)
|
||||
h -= 0.45 * (1.0 - fr) * band(np.abs(x), 0.020, 0.110, 0.030) * ridge(u, 0.408, 0.0090)
|
||||
|
||||
# ---- hamstrings: two bellies down the back of the thigh ---------------------------
|
||||
hm = (1.0 - fr) * band(u, 0.245, 0.395, 0.045)
|
||||
h += 0.45 * hm * (ridge(ctr, -0.026, 0.020) + ridge(ctr, 0.026, 0.020)
|
||||
- 0.60 * ridge(ctr, 0.0, 0.013))
|
||||
|
||||
# ---- patella, tibialis, achilles --------------------------------------------------
|
||||
h += 0.38 * fr * band(u, 0.196, 0.238, 0.020) * ridge(ctr, 0.0, 0.026)
|
||||
h += 0.30 * fr * band(u, 0.100, 0.195, 0.038) * ridge(ctr, -0.014, 0.017)
|
||||
h -= 0.35 * (1.0 - fr) * band(u, 0.078, 0.120, 0.024) * ridge(ctr, 0.0, 0.014)
|
||||
|
||||
# ---- forearm flexor mass ----------------------------------------------------------
|
||||
h += 0.30 * band(np.abs(sn), 0.615, 0.735, 0.055) * ridge(u, 0.700, 0.030)
|
||||
|
||||
# never put detail back into what was deliberately made featureless
|
||||
breast = fr * band(u, 0.632, 0.782, 0.028) * band(np.abs(x), 0.0, 0.118, 0.028)
|
||||
crotch = band(np.abs(x), -1.0, 0.085, 0.022) * band(u, 0.330, 0.480, 0.030)
|
||||
return h * np.clip(1.0 - np.maximum(breast, crotch), 0.0, 1.0)
|
||||
|
||||
|
||||
# ---- rasterise the height field into the atlas -------------------------------------------
|
||||
src = {}
|
||||
for slot in ob.material_slots:
|
||||
if not slot.material or not slot.material.node_tree:
|
||||
continue
|
||||
for node in slot.material.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
|
||||
W, H = src["base"].size
|
||||
loops_v = np.empty(n_l, dtype=np.int32); me.loops.foreach_get("vertex_index", loops_v)
|
||||
uv = np.empty(n_l * 2); me.uv_layers.active.data.foreach_get("uv", uv); uv = uv.reshape(-1, 2)
|
||||
ls = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_start", ls)
|
||||
lt = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_total", lt)
|
||||
li = ls[lt == 3]
|
||||
IDX = np.stack([li, li + 1, li + 2], axis=1)
|
||||
V = loops_v[IDX]
|
||||
P = np.stack([np.clip(uv[IDX][:, :, 0], 0, 1) * (W - 1),
|
||||
np.clip(uv[IDX][:, :, 1], 0, 1) * (H - 1)], axis=2)
|
||||
|
||||
# Per-face tangent frames. The normal has to be built from a 3D gradient projected into these,
|
||||
# not from a gradient taken in atlas space — see the note at the gradient below.
|
||||
E1 = co[V[:, 1]] - co[V[:, 0]]
|
||||
E2 = co[V[:, 2]] - co[V[:, 0]]
|
||||
UVt = uv[IDX]
|
||||
D1 = UVt[:, 1] - UVt[:, 0]
|
||||
D2 = UVt[:, 2] - UVt[:, 0]
|
||||
detf = D1[:, 0] * D2[:, 1] - D2[:, 0] * D1[:, 1]
|
||||
safef = np.where(np.abs(detf) < 1e-20, 1.0, detf)
|
||||
Tf = (E1 * D2[:, 1:2] - E2 * D1[:, 1:2]) / safef[:, None]
|
||||
Bf = (E2 * D1[:, 0:1] - E1 * D2[:, 0:1]) / safef[:, None]
|
||||
Nf = np.cross(E1, E2)
|
||||
Nf /= np.maximum(np.linalg.norm(Nf, axis=1, keepdims=True), 1e-20)
|
||||
Tf = Tf - Nf * (Nf * Tf).sum(axis=1, keepdims=True)
|
||||
Tf /= np.maximum(np.linalg.norm(Tf, axis=1, keepdims=True), 1e-20)
|
||||
Bx = np.cross(Nf, Tf)
|
||||
Bf = Bx * np.sign((Bx * Bf).sum(axis=1))[:, None]
|
||||
|
||||
Pmap = np.zeros((H, W, 3))
|
||||
Tmap = np.zeros((H, W, 3))
|
||||
Bmap = np.zeros((H, W, 3))
|
||||
cov = np.zeros((H, W), dtype=bool)
|
||||
for f in range(len(P)):
|
||||
p3 = P[f]
|
||||
x0, x1 = int(p3[:, 0].min()), int(np.ceil(p3[:, 0].max()))
|
||||
y0, y1 = int(p3[:, 1].min()), int(np.ceil(p3[:, 1].max()))
|
||||
if x1 < x0 or y1 < y0 or x1 - x0 > 512 or y1 - y0 > 512:
|
||||
continue
|
||||
det = ((p3[1, 1] - p3[2, 1]) * (p3[0, 0] - p3[2, 0])
|
||||
+ (p3[2, 0] - p3[1, 0]) * (p3[0, 1] - p3[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 = ((p3[1, 1] - p3[2, 1]) * (gx - p3[2, 0]) + (p3[2, 0] - p3[1, 0]) * (gy - p3[2, 1])) / det
|
||||
b = ((p3[2, 1] - p3[0, 1]) * (gx - p3[2, 0]) + (p3[0, 0] - p3[2, 0]) * (gy - p3[2, 1])) / det
|
||||
c = 1.0 - a - b
|
||||
ins = (a >= -0.02) & (b >= -0.02) & (c >= -0.02)
|
||||
if not ins.any():
|
||||
continue
|
||||
Pmap[gy[ins], gx[ins]] = (a[ins, None] * co[V[f, 0]] + b[ins, None] * co[V[f, 1]]
|
||||
+ c[ins, None] * co[V[f, 2]])
|
||||
Tmap[gy[ins], gx[ins]] = Tf[f]
|
||||
Bmap[gy[ins], gx[ins]] = Bf[f]
|
||||
cov[gy[ins], gx[ins]] = True
|
||||
log(f"rasterised position into {int(cov.sum())} texels")
|
||||
|
||||
# pad position outward first, so the field is defined a little past every chart border and the
|
||||
# gradient near a seam differentiates against real anatomy rather than against zero
|
||||
havep = cov.copy()
|
||||
for _ in range(8):
|
||||
Wf = havep.astype(np.float64)
|
||||
acc = np.zeros_like(Pmap); wac = np.zeros((H, W))
|
||||
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||||
acc += np.roll(Pmap * Wf[:, :, None], (dy, dx), axis=(0, 1))
|
||||
wac += np.roll(Wf, (dy, dx), axis=(0, 1))
|
||||
new = (~havep) & (wac > 0)
|
||||
if not new.any():
|
||||
break
|
||||
Pmap[new] = acc[new] / wac[new, None]
|
||||
for M_ in (Tmap, Bmap):
|
||||
accm = np.zeros_like(M_)
|
||||
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||||
accm += np.roll(M_ * Wf[:, :, None], (dy, dx), axis=(0, 1))
|
||||
M_[new] = accm[new] / wac[new, None]
|
||||
havep |= new
|
||||
|
||||
# evaluate the anatomy at texel resolution
|
||||
CX = 0.5 * (lo[0] + hi[0])
|
||||
CY = 0.5 * (lo[1] + hi[1])
|
||||
|
||||
|
||||
def h_at(P):
|
||||
xx = P[..., 0] - CX
|
||||
return muscle_field((P[..., 2] - lo[2]) / span[2], xx, P[..., 1] - CY, xx / HALF)
|
||||
|
||||
|
||||
Hraw = h_at(Pmap)
|
||||
peak = np.abs(Hraw[cov]).max()
|
||||
scale = (AMP_MM * STRENGTH) / peak if peak > 1e-9 else 0.0
|
||||
Himg = Hraw * scale
|
||||
log(f"height field per texel: {Himg[cov].min():+.3f} .. {Himg[cov].max():+.3f} mm, "
|
||||
f"{int((np.abs(Himg) > 0.1)[cov].sum())} texels with relief")
|
||||
|
||||
# ---- height -> tangent-space normal, via a THREE-DIMENSIONAL gradient --------------------
|
||||
# The previous version differentiated the height map in ATLAS space. That is valid inside a
|
||||
# chart, but the spinal groove sits at x = 0, which is exactly where 24_seams.py cuts the back
|
||||
# midline — so the two sides of the spine live on opposite EDGES of the torso chart. At those
|
||||
# edges the atlas-space difference reaches into padding rather than across the body, and the
|
||||
# groove came out as a hard ragged line down her whole back.
|
||||
#
|
||||
# Differentiating the anatomy in 3D and projecting into each texel's own tangent frame never
|
||||
# asks the atlas who a texel's neighbour is, so it is seam-correct by construction — for the
|
||||
# spine and equally for the inner-leg and arm seams.
|
||||
for _ in range(3):
|
||||
for M_ in (Tmap, Bmap):
|
||||
sm = np.zeros_like(M_)
|
||||
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1), (0, 0)):
|
||||
sm += np.roll(M_, (dy, dx), axis=(0, 1))
|
||||
M_[havep] = (sm / 5.0)[havep]
|
||||
for M_ in (Tmap, Bmap):
|
||||
M_ /= np.maximum(np.linalg.norm(M_, axis=2, keepdims=True), 1e-20)
|
||||
|
||||
sel = np.nonzero(havep)
|
||||
Pm = Pmap[sel]
|
||||
EPS = 0.0009 # ~1.6 mm, well inside the narrowest groove
|
||||
grad = np.empty((len(Pm), 3))
|
||||
for ax in range(3):
|
||||
dp = np.zeros(3)
|
||||
dp[ax] = EPS
|
||||
grad[:, ax] = (h_at(Pm + dp) - h_at(Pm - dp)) / (2.0 * EPS)
|
||||
grad *= scale / MM # mm of relief per mm of surface = a true slope
|
||||
Tm = Tmap[sel]
|
||||
Bm = Bmap[sel]
|
||||
Tm /= np.maximum(np.linalg.norm(Tm, axis=1, keepdims=True), 1e-20)
|
||||
Bm /= np.maximum(np.linalg.norm(Bm, axis=1, keepdims=True), 1e-20)
|
||||
sT = (grad * Tm).sum(axis=1)
|
||||
sB = (grad * Bm).sum(axis=1)
|
||||
nx = np.zeros((H, W)); ny = np.zeros((H, W)); nz = np.ones((H, W))
|
||||
ln = np.sqrt(sT * sT + sB * sB + 1.0)
|
||||
nx[sel] = -sT / ln
|
||||
ny[sel] = -sB / ln
|
||||
nz[sel] = 1.0 / ln
|
||||
log(f"slope: p99 {np.percentile(np.abs(sT), 99):.4f}, "
|
||||
f"max normal tilt {np.degrees(np.arccos(nz[cov].min())):.1f} deg")
|
||||
|
||||
im = src["normal"]
|
||||
b2 = np.empty(W * H * 4, dtype=np.float32); im.pixels.foreach_get(b2)
|
||||
arr = b2.reshape(H, W, 4)
|
||||
base_n = arr[:, :, :3].astype(np.float64) * 2.0 - 1.0
|
||||
# combine: add the muscle slope to whatever detail is already there, then renormalise
|
||||
cx = base_n[:, :, 0] + nx
|
||||
cy = base_n[:, :, 1] + ny
|
||||
cz = np.maximum(base_n[:, :, 2], 0.05)
|
||||
cl = np.sqrt(cx * cx + cy * cy + cz * cz)
|
||||
out_n = np.stack([cx / cl, cy / cl, cz / cl], axis=2) * 0.5 + 0.5
|
||||
arr[:, :, :3] = np.where(cov[:, :, None], out_n, arr[:, :, :3]).astype(np.float32)
|
||||
im.pixels.foreach_set(arr.reshape(-1)); im.pack()
|
||||
log("normal map updated")
|
||||
|
||||
# ---- faint cavity darkening in the albedo (relightable, unlike painted shading) -----------
|
||||
# pointwise, so it cannot pick up a seam either: the valleys ARE the negative
|
||||
# part of the height field
|
||||
cav = np.clip(-Himg, 0.0, None)
|
||||
if cav[cov].max() > 1e-9:
|
||||
cav = cav / cav[cov].max()
|
||||
cav = np.clip(cav, 0, 1)
|
||||
bi = src["base"]
|
||||
b3 = np.empty(W * H * 4, dtype=np.float32); bi.pixels.foreach_get(b3)
|
||||
brgb = b3.reshape(H, W, 4)
|
||||
alb = brgb[:, :, :3].astype(np.float64)
|
||||
alb = np.where(cov[:, :, None], alb * (1.0 - CAVITY * STRENGTH * cav[:, :, None]), alb)
|
||||
brgb[:, :, :3] = np.clip(alb, 0, 1).astype(np.float32)
|
||||
bi.pixels.foreach_set(brgb.reshape(-1)); bi.pack()
|
||||
log(f"albedo cavity applied (max darkening {100*CAVITY*STRENGTH:.1f}%)")
|
||||
|
||||
for k, img in src.items():
|
||||
stem = os.path.splitext(os.path.basename(OUT))[0]
|
||||
p = os.path.join(os.path.dirname(os.path.abspath(OUT)), f"{stem}_{k}.jpg")
|
||||
img.file_format = 'JPEG'
|
||||
img.filepath_raw = p
|
||||
img.save(filepath=p)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=OUT)
|
||||
if GLB:
|
||||
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=os.path.abspath(GLB), export_format='GLB',
|
||||
use_selection=True, export_image_format='AUTO',
|
||||
export_jpeg_quality=95, export_yup=True, export_apply=False)
|
||||
log(f"EXPORTED {GLB}")
|
||||
print("MUSCLE_DONE")
|
||||
Reference in New Issue
Block a user