Files
jeremy 3d8825f5a9 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>
2026-08-12 07:17:15 -07:00

201 lines
7.8 KiB
Python

# Stage 40: the OTHER half of the speckle. 39_despeckle.py cut the geometric folds (892 -> 361
# edges over 70 deg) and the clay render is now clean, but the textured render still shows flecks:
# they are painted into the albedo — scan noise from the Tripo source, faithfully resampled into
# the new atlas. Clay vs textured is what separates the two, and both had to be fixed.
#
# blender --background --python 40_texspeckle.py -- <in.blend> <out.blend> [out.glb]
#
# Detection is a local-contrast outlier test: a fleck is a small blob that is much darker (or
# brighter) than the skin immediately around it. Two guards keep it from eating her:
# - BLOB SIZE. Only components under MAX_BLOB texels are touched, so lips, brows, eyes and
# nostrils — which are large, coherent regions — are never candidates.
# - THE HEAD IS EXCLUDED OUTRIGHT. Her face is legitimately high-contrast and is the one place
# a contrast test cannot be trusted. Faces above the neck landmark are rasterised into a
# protect mask first.
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")), "")
t0 = time.time()
THRESH = 0.040 # local-contrast deviation that counts as a fleck
MAX_BLOB = 400 # texels; above this it is a feature, not a fleck
NECK_U = 0.825 # same landmark 24_seams.py measured
def log(m):
print(f"[tspk {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)
base = None
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' and node.inputs["Base Color"].links:
nd = node.inputs["Base Color"].links[0].from_node
if nd.type == 'TEX_IMAGE':
base = nd.image
W, H = base.size
buf = np.empty(W * H * 4, dtype=np.float32)
base.pixels.foreach_get(buf)
tex = buf.reshape(H, W, 4)
rgb = tex[:, :, :3].astype(np.float64)
log(f"atlas '{base.name}' {W}x{H}")
# ---- protect the head, and know where the atlas actually has skin ----
co = np.empty(n_v * 3); me.vertices.foreach_get("co", co); co = co.reshape(-1, 3)
u = (co[:, 2] - co[:, 2].min()) / (co[:, 2].max() - co[:, 2].min())
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)
protect = np.zeros((H, W), dtype=bool)
covered = np.zeros((H, W), dtype=bool)
is_head = u > NECK_U
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
covered[gy[ins], gx[ins]] = True
if is_head[V[f]].any():
protect[gy[ins], gx[ins]] = True
log(f"atlas coverage {100.0*covered.mean():.1f}%, head protected {100.0*protect.mean():.1f}%")
def box(a, r):
def b1(x, ax):
pad = [(0, 0)] * x.ndim
pad[ax] = (r, r)
cs = np.cumsum(np.pad(x, pad, mode="edge"), axis=ax)
return (np.take(cs, np.arange(2 * r, cs.shape[ax]), axis=ax)
- np.take(cs, np.arange(0, cs.shape[ax] - 2 * r), axis=ax)) / (2 * r)
return b1(b1(a, 0), 1)
lum = rgb.mean(axis=2)
bg = box(lum, 6)
dev = bg - lum
cand = covered & ~protect & (np.abs(dev) > THRESH)
log(f"local-contrast outliers: {int(cand.sum())} texels ({100.0*cand.mean():.3f}%)")
# blob-size filter: keep only small ones
lab = np.zeros((H, W), dtype=np.int32)
seen = np.zeros((H, W), dtype=bool)
from collections import deque
spots = np.zeros((H, W), dtype=bool)
ys, xs = np.nonzero(cand)
big_kept = 0
for y0, x0 in zip(ys, xs):
if seen[y0, x0]:
continue
q = deque([(y0, x0)])
seen[y0, x0] = True
cells = []
while q:
y, x = q.popleft()
cells.append((y, x))
if len(cells) > MAX_BLOB:
break
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
yy, xx = y + dy, x + dx
if 0 <= yy < H and 0 <= xx < W and cand[yy, xx] and not seen[yy, xx]:
seen[yy, xx] = True
q.append((yy, xx))
while q:
y, x = q.popleft()
seen[y, x] = True
if len(cells) <= MAX_BLOB:
for y, x in cells:
spots[y, x] = True
else:
big_kept += 1
log(f"flecks (blobs <= {MAX_BLOB} px): {int(spots.sum())} texels; "
f"{big_kept} larger regions left alone as features")
# grow slightly so the fleck's soft edge goes too
g = spots.copy()
for _ in range(2):
n2 = g.copy()
n2[1:, :] |= g[:-1, :]; n2[:-1, :] |= g[1:, :]
n2[:, 1:] |= g[:, :-1]; n2[:, :-1] |= g[:, 1:]
g = n2
spots = g & covered & ~protect
log(f"after grow: {int(spots.sum())} texels ({100.0*spots.mean():.3f}% of the atlas)")
# ---- inpaint: neighbour-average diffusion from clean skin around each fleck ----
C = rgb.copy()
have = covered & ~spots
for _ in range(24):
todo = spots & ~have
if not todo.any():
break
Wf = have.astype(np.float64)
acc = np.zeros_like(C)
wac = np.zeros((H, W))
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 = todo & (wac > 0)
if not new.any():
break
C[new] = acc[new] / wac[new, None]
have |= new
# a couple of smoothing passes confined to the repaired texels, so the patch is not blocky
for _ in range(2):
sm = np.stack([box(C[:, :, c], 2) for c in range(3)], axis=2)
C[spots] = sm[spots]
log(f"inpainted {int((spots & have).sum())} texels")
b4 = tex.copy()
b4[:, :, :3] = np.clip(C, 0, 1).astype(np.float32)
base.pixels.foreach_set(b4.reshape(-1))
base.pack()
# name the loose sidecar after the output blend: a fixed name made every run overwrite the
# previous version's copy and left both blends pointing at the same path
stem = os.path.splitext(os.path.basename(OUT))[0]
p = os.path.join(os.path.dirname(os.path.abspath(OUT)), f"{stem}_base.jpg")
base.file_format = 'JPEG'
base.filepath_raw = p
base.save(filepath=p)
log(f"wrote {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("TEXSPECKLE_DONE")