276 lines
11 KiB
Python
276 lines
11 KiB
Python
|
|
# Stage 31: resample the existing maps into the new anatomical atlas, then export the re-atlased
|
||
|
|
# body.
|
||
|
|
#
|
||
|
|
# blender --background --python 31_reatlas.py -- <seamed.blend> <out_dir> <out.glb>
|
||
|
|
#
|
||
|
|
# Input is 24_seams.py's output, which carries TWO uv layers: 'UVMap_tripo' (the source 5,870-chart
|
||
|
|
# atlas, still holding the textures) and 'UVMap_atlas' (the new ~14-chart layout, empty). This walks
|
||
|
|
# every triangle in NEW atlas space, barycentrically recovers the OLD uv per texel, and samples the
|
||
|
|
# source maps there. Same mesh, same triangles, so the transfer is exact — no projection, no BVH,
|
||
|
|
# no Cycles bake.
|
||
|
|
#
|
||
|
|
# The normal map cannot simply be copied. It is tangent-space, and re-atlasing rotates (and
|
||
|
|
# sometimes mirrors) every chart, so its frame moves. Per face this computes the old and new
|
||
|
|
# tangent frames about the shared geometric normal and rotates the stored vector between them;
|
||
|
|
# skipping that would light her detail from the wrong direction, subtly and everywhere.
|
||
|
|
import bpy, sys, os, time
|
||
|
|
import numpy as np
|
||
|
|
|
||
|
|
argv = sys.argv[sys.argv.index("--") + 1:]
|
||
|
|
BLEND, OUTDIR, OUTGLB = argv[0], os.path.abspath(argv[1]), os.path.abspath(argv[2])
|
||
|
|
os.makedirs(OUTDIR, exist_ok=True)
|
||
|
|
RES = int(argv[3]) if len(argv) > 3 else 4096
|
||
|
|
PAD = 16
|
||
|
|
t0 = time.time()
|
||
|
|
|
||
|
|
|
||
|
|
def log(m):
|
||
|
|
print(f"[atlas {time.time()-t0:6.1f}s] {m}", flush=True)
|
||
|
|
|
||
|
|
|
||
|
|
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
||
|
|
ob = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
|
||
|
|
me = ob.data
|
||
|
|
names = [l.name for l in me.uv_layers]
|
||
|
|
assert "UVMap_tripo" in names and "UVMap_atlas" in names, f"expected both uv layers, got {names}"
|
||
|
|
n_v, n_l, n_f = len(me.vertices), len(me.loops), len(me.polygons)
|
||
|
|
log(f"mesh {n_v}v {n_f}f uv layers {names}")
|
||
|
|
|
||
|
|
# ---- source maps, resolved through the material graph (the file holds stale duplicates) ----
|
||
|
|
src = {}
|
||
|
|
for slot in ob.material_slots:
|
||
|
|
mat = slot.material
|
||
|
|
if not mat or not mat.node_tree:
|
||
|
|
continue
|
||
|
|
for node in mat.node_tree.nodes:
|
||
|
|
if node.type != 'BSDF_PRINCIPLED':
|
||
|
|
continue
|
||
|
|
for sock, key in (("Base Color", "base"), ("Normal", "normal"), ("Roughness", "rm")):
|
||
|
|
if sock not in node.inputs or not node.inputs[sock].links:
|
||
|
|
continue
|
||
|
|
nd = node.inputs[sock].links[0].from_node
|
||
|
|
seen = set()
|
||
|
|
while nd and nd.type != 'TEX_IMAGE' and id(nd) not in seen:
|
||
|
|
seen.add(id(nd))
|
||
|
|
nxt = None
|
||
|
|
for i in nd.inputs:
|
||
|
|
if i.links:
|
||
|
|
nxt = i.links[0].from_node
|
||
|
|
break
|
||
|
|
nd = nxt
|
||
|
|
if nd and nd.type == 'TEX_IMAGE' and nd.image:
|
||
|
|
src[key] = nd.image
|
||
|
|
for k, im in src.items():
|
||
|
|
log(f"source {k}: '{im.name}' {im.size[0]}x{im.size[1]}")
|
||
|
|
assert "base" in src, "no basecolor found"
|
||
|
|
|
||
|
|
|
||
|
|
def px_of(img):
|
||
|
|
w, h = img.size
|
||
|
|
b = np.empty(w * h * 4, dtype=np.float32)
|
||
|
|
img.pixels.foreach_get(b)
|
||
|
|
return b.reshape(h, w, 4)[:, :, :3].astype(np.float32), w, h
|
||
|
|
|
||
|
|
|
||
|
|
# Colour management is not cosmetic here. `image.pixels` hands back linear values for an sRGB
|
||
|
|
# image and raw values for a Non-Color one, and re-encodes the same way on save. Write raw
|
||
|
|
# normal/rm data into a default (sRGB) image and saving gamma-encodes it: 0.5 comes back as 0.21,
|
||
|
|
# so every stored normal becomes a large false perturbation and the body shades dark and glossy.
|
||
|
|
# Each new map must therefore inherit its source's colorspace exactly.
|
||
|
|
for k, im in src.items():
|
||
|
|
log(f" {k} colorspace: {im.colorspace_settings.name}")
|
||
|
|
|
||
|
|
|
||
|
|
# ---- geometry + both uv sets ----
|
||
|
|
loops_v = np.empty(n_l, dtype=np.int32); me.loops.foreach_get("vertex_index", loops_v)
|
||
|
|
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
|
||
|
|
l_start = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_start", l_start)
|
||
|
|
l_tot = np.empty(n_f, dtype=np.int32); me.polygons.foreach_get("loop_total", l_tot)
|
||
|
|
li = l_start[l_tot == 3]
|
||
|
|
nT = len(li)
|
||
|
|
|
||
|
|
|
||
|
|
def uv_of(name):
|
||
|
|
a = np.empty(n_l * 2)
|
||
|
|
me.uv_layers[name].data.foreach_get("uv", a)
|
||
|
|
return a.reshape(-1, 2)
|
||
|
|
|
||
|
|
|
||
|
|
UO = uv_of("UVMap_tripo")
|
||
|
|
UN = uv_of("UVMap_atlas")
|
||
|
|
IDX = np.stack([li, li + 1, li + 2], axis=1)
|
||
|
|
VI = loops_v[IDX] # (T,3) vertex index
|
||
|
|
Qo = np.clip(UO[IDX], 0.0, 1.0) # (T,3,2) source uv
|
||
|
|
Pn = np.clip(UN[IDX], 0.0, 1.0) # (T,3,2) new uv
|
||
|
|
P3 = co[VI] # (T,3,3)
|
||
|
|
log(f"{nT} triangles")
|
||
|
|
|
||
|
|
# ---- per-face tangent frames, for the normal-map rotation ----
|
||
|
|
e1 = P3[:, 1] - P3[:, 0]
|
||
|
|
e2 = P3[:, 2] - P3[:, 0]
|
||
|
|
N = np.cross(e1, e2)
|
||
|
|
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-20)
|
||
|
|
|
||
|
|
|
||
|
|
def tangent(Q):
|
||
|
|
d1 = Q[:, 1] - Q[:, 0]
|
||
|
|
d2 = Q[:, 2] - Q[:, 0]
|
||
|
|
det = d1[:, 0] * d2[:, 1] - d2[:, 0] * d1[:, 1]
|
||
|
|
r = np.where(np.abs(det) < 1e-20, 0.0, 1.0 / np.where(det == 0, 1.0, det))
|
||
|
|
T = (e1 * d2[:, 1:2] - e2 * d1[:, 1:2]) * r[:, None]
|
||
|
|
B = (e2 * d1[:, 0:1] - e1 * d2[:, 0:1]) * r[:, None]
|
||
|
|
# orthonormalise against the shared geometric normal
|
||
|
|
T = T - N * (N * T).sum(axis=1, keepdims=True)
|
||
|
|
ln = np.linalg.norm(T, axis=1, keepdims=True)
|
||
|
|
bad = (ln[:, 0] < 1e-12)
|
||
|
|
T = np.where(bad[:, None], np.cross(N, [0.0, 0.0, 1.0]), T / np.maximum(ln, 1e-20))
|
||
|
|
ln = np.maximum(np.linalg.norm(T, axis=1, keepdims=True), 1e-20)
|
||
|
|
T = T / ln
|
||
|
|
w = np.sign((np.cross(N, T) * B).sum(axis=1))
|
||
|
|
w = np.where(w == 0, 1.0, w)
|
||
|
|
return T, np.cross(N, T) * w[:, None]
|
||
|
|
|
||
|
|
|
||
|
|
To, Bo = tangent(Qo)
|
||
|
|
Tn, Bn = tangent(Pn)
|
||
|
|
# (nx',ny') = M . (nx,ny) — both frames share N, so nz is unchanged
|
||
|
|
M00 = (To * Tn).sum(axis=1); M01 = (Bo * Tn).sum(axis=1)
|
||
|
|
M10 = (To * Bn).sum(axis=1); M11 = (Bo * Bn).sum(axis=1)
|
||
|
|
rot = np.degrees(np.arctan2(M10, M00))
|
||
|
|
log(f"tangent frame rotation between atlases: p50 {np.percentile(np.abs(rot),50):.1f} deg, "
|
||
|
|
f"p95 {np.percentile(np.abs(rot),95):.1f} deg, max {np.abs(rot).max():.1f} deg")
|
||
|
|
|
||
|
|
# ---- rasterise the new atlas ----
|
||
|
|
W = H = RES
|
||
|
|
out = {k: np.zeros((H, W, 3), dtype=np.float32) for k in src}
|
||
|
|
srcpx = {k: px_of(v) for k, v in src.items()}
|
||
|
|
mask = np.zeros((H, W), dtype=bool)
|
||
|
|
|
||
|
|
|
||
|
|
def bilinear(A, w, h, uu, vv):
|
||
|
|
x = np.clip(uu, 0, 1) * (w - 1)
|
||
|
|
y = np.clip(vv, 0, 1) * (h - 1)
|
||
|
|
x0 = np.floor(x).astype(np.int32); y0 = np.floor(y).astype(np.int32)
|
||
|
|
x1 = np.minimum(x0 + 1, w - 1); y1 = np.minimum(y0 + 1, h - 1)
|
||
|
|
fx = (x - x0)[:, None]; fy = (y - y0)[:, None]
|
||
|
|
return (A[y0, x0] * (1 - fx) * (1 - fy) + A[y0, x1] * fx * (1 - fy)
|
||
|
|
+ A[y1, x0] * (1 - fx) * fy + A[y1, x1] * fx * fy)
|
||
|
|
|
||
|
|
|
||
|
|
Ppx = Pn * (W - 1)
|
||
|
|
TOL = -0.02 # slight over-rasterisation so charts have no interior gaps
|
||
|
|
done = 0
|
||
|
|
hitlist = []
|
||
|
|
for f in range(nT):
|
||
|
|
P = Ppx[f]
|
||
|
|
x0 = int(P[:, 0].min()); x1 = int(np.ceil(P[:, 0].max()))
|
||
|
|
y0 = int(P[:, 1].min()); y1 = int(np.ceil(P[:, 1].max()))
|
||
|
|
if x1 < x0 or y1 < y0 or (x1 - x0) > 512 or (y1 - y0) > 512:
|
||
|
|
continue
|
||
|
|
det = ((P[1, 1] - P[2, 1]) * (P[0, 0] - P[2, 0])
|
||
|
|
+ (P[2, 0] - P[1, 0]) * (P[0, 1] - P[2, 1]))
|
||
|
|
if abs(det) < 1e-12:
|
||
|
|
continue
|
||
|
|
gx, gy = np.meshgrid(np.arange(max(x0, 0), min(x1, W - 1) + 1),
|
||
|
|
np.arange(max(y0, 0), min(y1, H - 1) + 1))
|
||
|
|
if gx.size == 0:
|
||
|
|
continue
|
||
|
|
a = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / det
|
||
|
|
b = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / det
|
||
|
|
c = 1.0 - a - b
|
||
|
|
ins = (a >= TOL) & (b >= TOL) & (c >= TOL)
|
||
|
|
if not ins.any():
|
||
|
|
continue
|
||
|
|
# Write the slightly-outside rim pixels (TOL) but SAMPLE from strictly inside the source
|
||
|
|
# triangle — the old atlas is 38% empty, and extrapolating past a source triangle's edge
|
||
|
|
# reads black gap and leaves a dark fringe on every chart border.
|
||
|
|
aa = np.clip(a[ins], 0.0, 1.0); bb = np.clip(b[ins], 0.0, 1.0); cc = np.clip(c[ins], 0.0, 1.0)
|
||
|
|
tot = np.maximum(aa + bb + cc, 1e-12)
|
||
|
|
aa, bb, cc = aa / tot, bb / tot, cc / tot
|
||
|
|
ou = aa * Qo[f, 0, 0] + bb * Qo[f, 1, 0] + cc * Qo[f, 2, 0]
|
||
|
|
ov = aa * Qo[f, 0, 1] + bb * Qo[f, 1, 1] + cc * Qo[f, 2, 1]
|
||
|
|
yy, xx = gy[ins], gx[ins]
|
||
|
|
for k, (A, w, h) in srcpx.items():
|
||
|
|
val = bilinear(A, w, h, ou, ov)
|
||
|
|
if k == "normal":
|
||
|
|
nx = val[:, 0] * 2.0 - 1.0
|
||
|
|
ny = val[:, 1] * 2.0 - 1.0
|
||
|
|
val = np.stack([(nx * M00[f] + ny * M01[f]) * 0.5 + 0.5,
|
||
|
|
(nx * M10[f] + ny * M11[f]) * 0.5 + 0.5,
|
||
|
|
val[:, 2]], axis=1)
|
||
|
|
out[k][yy, xx] = val
|
||
|
|
mask[yy, xx] = True
|
||
|
|
hitlist.append(f)
|
||
|
|
done += 1
|
||
|
|
skipped = np.ones(nT, dtype=bool)
|
||
|
|
skipped[hitlist] = False
|
||
|
|
a3 = 0.5 * np.linalg.norm(np.cross(e1, e2), axis=1)
|
||
|
|
UNITM = 1.777 / (co[:, 2].max() - co[:, 2].min())
|
||
|
|
log(f"rasterised {done}/{nT} triangles -> {int(mask.sum())} texels "
|
||
|
|
f"({100.0*mask.sum()/(W*H):.1f}% of the atlas)")
|
||
|
|
log(f"skipped {int(skipped.sum())} triangles holding "
|
||
|
|
f"{a3[skipped].sum()*UNITM*UNITM*1e4:.2f} cm2 of "
|
||
|
|
f"{a3.sum()*UNITM*UNITM*1e4:.0f} cm2 total ({100.0*a3[skipped].sum()/a3.sum():.3f}%) "
|
||
|
|
f"— they are sub-texel slivers, covered by the padding pass")
|
||
|
|
|
||
|
|
# ---- pad outward so filtering and mip generation never pull in empty space ----
|
||
|
|
have = mask.copy()
|
||
|
|
for k in out:
|
||
|
|
C = out[k]
|
||
|
|
hv = mask.copy()
|
||
|
|
for _ in range(PAD):
|
||
|
|
acc = np.zeros_like(C)
|
||
|
|
wac = np.zeros((H, W), dtype=np.float32)
|
||
|
|
Wf = hv.astype(np.float32)
|
||
|
|
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||
|
|
acc += np.roll(C * Wf[:, :, None], (dy, dx), axis=(0, 1))
|
||
|
|
wac += np.roll(Wf, (dy, dx), axis=(0, 1))
|
||
|
|
new = (~hv) & (wac > 0)
|
||
|
|
if not new.any():
|
||
|
|
break
|
||
|
|
C[new] = acc[new] / wac[new, None]
|
||
|
|
hv |= new
|
||
|
|
have = hv
|
||
|
|
log(f"padded to {int(have.sum())} texels ({100.0*have.sum()/(W*H):.1f}%)")
|
||
|
|
|
||
|
|
# ---- write the maps, rewire the material, drop the source uv layer ----
|
||
|
|
newimg = {}
|
||
|
|
for k in out:
|
||
|
|
im = bpy.data.images.new(f"lena_nude_atlas_{k}", W, H, alpha=False,
|
||
|
|
is_data=(src[k].colorspace_settings.name != 'sRGB'))
|
||
|
|
im.colorspace_settings.name = src[k].colorspace_settings.name
|
||
|
|
buf = np.ones((H, W, 4), dtype=np.float32)
|
||
|
|
buf[:, :, :3] = np.clip(out[k], 0, 1)
|
||
|
|
im.pixels.foreach_set(buf.reshape(-1))
|
||
|
|
im.file_format = 'JPEG' # the source maps are JPEG; match them so the GLB stays small
|
||
|
|
p = os.path.join(OUTDIR, f"lena_nude_atlas_{k}.jpg")
|
||
|
|
im.filepath_raw = p
|
||
|
|
im.save(filepath=p)
|
||
|
|
im.pack()
|
||
|
|
newimg[k] = im
|
||
|
|
log(f"wrote {p}")
|
||
|
|
|
||
|
|
for slot in ob.material_slots:
|
||
|
|
mat = slot.material
|
||
|
|
if not mat or not mat.node_tree:
|
||
|
|
continue
|
||
|
|
for node in mat.node_tree.nodes:
|
||
|
|
if node.type == 'TEX_IMAGE' and node.image:
|
||
|
|
for k, old in src.items():
|
||
|
|
if node.image == old:
|
||
|
|
node.image = newimg[k]
|
||
|
|
me.uv_layers.active = me.uv_layers["UVMap_atlas"]
|
||
|
|
me.uv_layers.remove(me.uv_layers["UVMap_tripo"])
|
||
|
|
me.uv_layers["UVMap_atlas"].name = "UVMap"
|
||
|
|
log(f"uv layers now {[l.name for l in me.uv_layers]}")
|
||
|
|
|
||
|
|
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUTDIR, "reatlased.blend"))
|
||
|
|
for o in bpy.data.objects:
|
||
|
|
o.select_set(o is ob)
|
||
|
|
bpy.context.view_layer.objects.active = ob
|
||
|
|
bpy.ops.export_scene.gltf(filepath=OUTGLB, export_format='GLB', use_selection=True,
|
||
|
|
export_image_format='AUTO', export_jpeg_quality=95,
|
||
|
|
export_yup=True, export_apply=False)
|
||
|
|
log(f"EXPORTED {OUTGLB} ({os.path.getsize(OUTGLB)/1e6:.1f} MB)")
|
||
|
|
print("REATLAS_DONE")
|