3d8825f5a9
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>
234 lines
9.3 KiB
Python
234 lines
9.3 KiB
Python
# Stage 20 (read-only probe): characterise the CURRENT UV atlas, so "the texture looks cut up
|
|
# and pasted together" becomes a measurement instead of an impression.
|
|
#
|
|
# blender --background --python 20_atlas_probe.py -- <in.blend> <out_dir>
|
|
#
|
|
# Reports, for the mesh's active UV layer:
|
|
# - which image the material actually samples (bpy.data.images holds stale duplicates)
|
|
# - UV-vertex count vs mesh-vertex count => how much the atlas is cut apart
|
|
# - island count + size distribution => "pasted together" from how many pieces
|
|
# - per-island texel density (px per mm) => whether pieces are at inconsistent scale
|
|
# - atlas coverage + wasted area
|
|
# Writes: basecolor.png (the real one), islands.png (island map), density.png (px/mm heat),
|
|
# uvgrid.png (UV wireframe), and uv_cache.npz for later stages.
|
|
import bpy, sys, os, time
|
|
import numpy as np
|
|
|
|
argv = sys.argv[sys.argv.index("--") + 1:]
|
|
BLEND = argv[0]
|
|
OUTDIR = os.path.abspath(argv[1])
|
|
os.makedirs(OUTDIR, exist_ok=True)
|
|
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
|
|
n_v, n_l, n_f = len(me.vertices), len(me.loops), len(me.polygons)
|
|
log(f"mesh '{ob.name}': {n_v}v {n_l}loops {n_f}faces uv_layers={[l.name for l in me.uv_layers]}")
|
|
|
|
# ---- which image does the material ACTUALLY sample? follow the node link ----
|
|
sampled = {}
|
|
for slot in ob.material_slots:
|
|
mat = slot.material
|
|
if not mat or not mat.use_nodes:
|
|
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
|
|
src = node.inputs[sock].links[0].from_node
|
|
seen = set()
|
|
while src and src.type != 'TEX_IMAGE' and id(src) not in seen:
|
|
seen.add(id(src))
|
|
nxt = None
|
|
for i in src.inputs:
|
|
if i.links:
|
|
nxt = i.links[0].from_node
|
|
break
|
|
src = nxt
|
|
if src and src.type == 'TEX_IMAGE' and src.image:
|
|
sampled[key] = src.image
|
|
for k, im in sampled.items():
|
|
log(f"SAMPLED {k}: '{im.name}' {im.size[0]}x{im.size[1]} packed={bool(im.packed_file)}")
|
|
print("ALL IMAGES IN FILE (duplicates are stale):")
|
|
for im in bpy.data.images:
|
|
if im.size[0]:
|
|
print(f" '{im.name}' {im.size[0]}x{im.size[1]}")
|
|
|
|
base = sampled.get("base")
|
|
if base is None:
|
|
print("!! material samples no basecolor image"); sys.exit(1)
|
|
W, H = base.size
|
|
buf = np.empty(W * H * 4, dtype=np.float32)
|
|
base.pixels.foreach_get(buf)
|
|
tex = buf.reshape(H, W, 4)
|
|
|
|
# ---- UV data ----
|
|
loops_v = np.empty(n_l, dtype=np.int32)
|
|
me.loops.foreach_get("vertex_index", loops_v)
|
|
uv = np.empty(n_l * 2, dtype=np.float64)
|
|
me.uv_layers.active.data.foreach_get("uv", uv)
|
|
uv = uv.reshape(-1, 2)
|
|
co = np.empty(n_v * 3)
|
|
me.vertices.foreach_get("co", co)
|
|
co = co.reshape(-1, 3)
|
|
|
|
print(f"UV range u {uv[:,0].min():.4f}..{uv[:,0].max():.4f} "
|
|
f"v {uv[:,1].min():.4f}..{uv[:,1].max():.4f}")
|
|
|
|
# ---- uv-vertices: a mesh vertex split across N atlas pieces becomes N uv-vertices ----
|
|
Q = 1 << 20
|
|
key = (loops_v.astype(np.int64) * Q * Q
|
|
+ np.round(np.clip(uv[:, 0], 0, 1) * (Q - 1)).astype(np.int64) * Q
|
|
+ np.round(np.clip(uv[:, 1], 0, 1) * (Q - 1)).astype(np.int64))
|
|
_, uvv = np.unique(key, return_inverse=True)
|
|
n_uvv = uvv.max() + 1
|
|
splits = np.bincount(uvv, minlength=n_uvv)
|
|
per_vert = np.bincount(loops_v, weights=np.zeros(n_l)) # placeholder
|
|
# how many atlas copies does each mesh vertex have?
|
|
vk = np.unique(np.stack([loops_v, uvv], axis=1), axis=0)
|
|
copies = np.bincount(vk[:, 0], minlength=n_v)
|
|
print(f"\n=== CUT-APART ===")
|
|
print(f"uv-vertices {n_uvv} for {n_v} mesh vertices -> {100.0*(n_uvv-n_v)/n_v:+.1f}% duplication")
|
|
print(f"vertices on a UV seam: {int((copies > 1).sum())} ({100.0*(copies>1).sum()/n_v:.1f}%) "
|
|
f"max copies {int(copies.max())}")
|
|
|
|
# ---- islands = connected components of the uv-mesh ----
|
|
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)
|
|
tri = l_tot == 3
|
|
log(f"faces: {int(tri.sum())} tris, {int((~tri).sum())} n-gons")
|
|
li = l_start[tri]
|
|
T = np.stack([uvv[li], uvv[li + 1], uvv[li + 2]], axis=1)
|
|
|
|
parent = np.arange(n_uvv, dtype=np.int64)
|
|
|
|
|
|
def find(x):
|
|
r = x
|
|
while parent[r] != r:
|
|
r = parent[r]
|
|
while parent[x] != r:
|
|
parent[x], x = r, parent[x]
|
|
return r
|
|
|
|
|
|
for a, b, c in T:
|
|
ra, rb, rc = find(a), find(b), find(c)
|
|
if ra != rb:
|
|
parent[rb] = ra
|
|
if ra != rc:
|
|
parent[rc] = ra
|
|
log("union-find done")
|
|
roots = np.array([find(i) for i in range(n_uvv)])
|
|
_, isl = np.unique(roots, return_inverse=True)
|
|
n_isl = isl.max() + 1
|
|
|
|
# per-island geometry: UV area and 3D area
|
|
Puv = np.clip(uv, 0, 1)
|
|
tri_uv = np.stack([Puv[li], Puv[li + 1], Puv[li + 2]], axis=1) # (F,3,2)
|
|
auv = 0.5 * np.abs((tri_uv[:, 1, 0] - tri_uv[:, 0, 0]) * (tri_uv[:, 2, 1] - tri_uv[:, 0, 1])
|
|
- (tri_uv[:, 2, 0] - tri_uv[:, 0, 0]) * (tri_uv[:, 1, 1] - tri_uv[:, 0, 1]))
|
|
P3 = co[np.stack([loops_v[li], loops_v[li + 1], loops_v[li + 2]], axis=1)] # (F,3,3)
|
|
cr = np.cross(P3[:, 1] - P3[:, 0], P3[:, 2] - P3[:, 0])
|
|
a3 = 0.5 * np.linalg.norm(cr, axis=1)
|
|
fisl = isl[T[:, 0]]
|
|
isl_auv = np.bincount(fisl, weights=auv, minlength=n_isl)
|
|
isl_a3 = np.bincount(fisl, weights=a3, minlength=n_isl)
|
|
isl_nf = np.bincount(fisl, minlength=n_isl)
|
|
|
|
order = np.argsort(-isl_auv)
|
|
UNIT = 1.815 # 1 mesh unit = 1.815 m (body is 0.979 units for 1.777 m)
|
|
print(f"\n=== PASTED TOGETHER ===")
|
|
print(f"islands: {n_isl}")
|
|
print(f"atlas UV area used: {isl_auv.sum()*100:.1f}% (rest is padding/waste)")
|
|
cum = np.cumsum(isl_auv[order]) / max(isl_auv.sum(), 1e-12)
|
|
for frac in (0.5, 0.9, 0.99):
|
|
print(f" {int(np.searchsorted(cum, frac))+1} islands cover {frac*100:.0f}% of the used area")
|
|
tiny = int((isl_nf < 20).sum())
|
|
print(f" islands with <20 faces: {tiny} ({100.0*tiny/n_isl:.1f}%)")
|
|
print("\ntop 20 islands (px/mm = texel density at 4096):")
|
|
print(" # faces uv_area% 3D area cm2 px/mm uv centre")
|
|
for i in order[:20]:
|
|
if isl_a3[i] <= 0:
|
|
continue
|
|
dens = np.sqrt(isl_auv[i] / isl_a3[i]) * W / (UNIT * 1000.0)
|
|
m = fisl == i
|
|
cu = tri_uv[m].reshape(-1, 2).mean(axis=0)
|
|
print(f" {i:6d} {isl_nf[i]:7d} {isl_auv[i]*100:8.3f} "
|
|
f"{isl_a3[i]*UNIT*UNIT*1e4:10.1f} {dens:6.2f} ({cu[0]:.3f},{cu[1]:.3f})")
|
|
|
|
big = order[:max(1, int(np.searchsorted(cum, 0.99)) + 1)]
|
|
dens_all = np.where(isl_a3 > 0, np.sqrt(np.maximum(isl_auv, 0) / np.maximum(isl_a3, 1e-12))
|
|
* W / (UNIT * 1000.0), np.nan)
|
|
d = dens_all[big]
|
|
d = d[np.isfinite(d)]
|
|
print(f"\ntexel density over the 99%-area islands: min {d.min():.2f} median {np.median(d):.2f} "
|
|
f"max {d.max():.2f} px/mm -> {d.max()/max(d.min(),1e-9):.1f}x spread")
|
|
|
|
# ---- pictures ----
|
|
def save(arr, name):
|
|
h, w = arr.shape[:2]
|
|
img = bpy.data.images.new(name, w, h, alpha=False, float_buffer=False)
|
|
a = np.ones((h, w, 4), dtype=np.float32)
|
|
a[:, :, :3] = arr.astype(np.float32)
|
|
img.pixels.foreach_set(a.reshape(-1))
|
|
p = os.path.join(OUTDIR, name + ".png")
|
|
img.file_format = 'PNG'
|
|
img.filepath_raw = p
|
|
img.save(filepath=p)
|
|
log(f"wrote {p} exists={os.path.exists(p)}")
|
|
|
|
|
|
save(tex[:, :, :3], "basecolor")
|
|
|
|
R = 1024
|
|
sc = R / float(W)
|
|
rng = np.random.RandomState(3)
|
|
pal = rng.rand(n_isl, 3) * 0.75 + 0.2
|
|
IS = np.zeros((R, R, 3), dtype=np.float64)
|
|
DN = np.zeros((R, R), dtype=np.float64)
|
|
GR = np.zeros((R, R), dtype=np.float64)
|
|
tri_px = tri_uv * np.array([(R - 1), (R - 1)])
|
|
for fi in range(len(tri_px)):
|
|
P = tri_px[fi]
|
|
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 or y1 < y0 or x1 - x0 > 64 or y1 - y0 > 64:
|
|
continue
|
|
dd = ((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(dd) < 1e-12:
|
|
continue
|
|
gx, gy = np.meshgrid(np.arange(x0, min(x1, R - 1) + 1), np.arange(y0, min(y1, R - 1) + 1))
|
|
aa = ((P[1, 1] - P[2, 1]) * (gx - P[2, 0]) + (P[2, 0] - P[1, 0]) * (gy - P[2, 1])) / dd
|
|
bb = ((P[2, 1] - P[0, 1]) * (gx - P[2, 0]) + (P[0, 0] - P[2, 0]) * (gy - P[2, 1])) / dd
|
|
cc = 1.0 - aa - bb
|
|
ins = (aa >= 0) & (bb >= 0) & (cc >= 0)
|
|
if not ins.any():
|
|
continue
|
|
IS[gy[ins], gx[ins]] = pal[fisl[fi]]
|
|
DN[gy[ins], gx[ins]] = dens_all[fisl[fi]] if np.isfinite(dens_all[fisl[fi]]) else 0
|
|
# edge pixels -> wireframe
|
|
ed = ins & ((aa < 0.06) | (bb < 0.06) | (cc < 0.06))
|
|
GR[gy[ed], gx[ed]] = 1.0
|
|
log("rasterised island map")
|
|
save(IS, "islands")
|
|
dv = DN / max(np.percentile(DN[DN > 0], 98), 1e-9)
|
|
save(np.stack([np.clip(dv, 0, 1), np.clip(1 - np.abs(dv - 0.5) * 2, 0, 1),
|
|
np.clip(1 - dv, 0, 1)], axis=2), "density")
|
|
tsm = tex[::W // R, ::W // R, :3]
|
|
save(np.clip(tsm * (1 - GR[:, :, None] * 0.8) + GR[:, :, None] * np.array([0.0, 1.0, 0.2]), 0, 1),
|
|
"uvgrid")
|
|
|
|
np.savez_compressed(os.path.join(OUTDIR, "uv_cache.npz"),
|
|
isl=isl, uvv=uvv, fisl=fisl, isl_auv=isl_auv, isl_a3=isl_a3,
|
|
isl_nf=isl_nf, copies=copies)
|
|
print("ATLAS_PROBE_DONE")
|