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>
128 lines
5.7 KiB
Python
128 lines
5.7 KiB
Python
# Stage 47: pose QC on the grafted body. Deformation is what exposes bad weights — a T-pose hides
|
|
# every one of them.
|
|
#
|
|
# blender --background --python 47_pose_qc.py -- <grafted.blend> <out_dir>
|
|
#
|
|
# Two independent checks per pose, because a render alone can hide a defect that faces away and a
|
|
# number alone cannot tell you whether it looks wrong:
|
|
# EDGE STRETCH — every edge's deformed length over its rest length. Skin stretches smoothly;
|
|
# a mis-weighted vertex tears one edge far beyond its neighbours, so the p99.99
|
|
# and the max are the tell, not the mean.
|
|
# RENDER — the same poses, so anything the numbers miss is still visible.
|
|
# Fingers get their own pose: close-packed digits are where this lane historically mangled weights.
|
|
import bpy, sys, os, math, time
|
|
import numpy as np
|
|
from mathutils import Vector, Euler
|
|
|
|
argv = sys.argv[sys.argv.index("--") + 1:]
|
|
BLEND, OUT = argv[0], os.path.abspath(argv[1])
|
|
os.makedirs(OUT, exist_ok=True)
|
|
t0 = time.time()
|
|
|
|
bpy.ops.wm.open_mainfile(filepath=BLEND)
|
|
arm = next(o for o in bpy.data.objects if o.type == 'ARMATURE')
|
|
body = max([o for o in bpy.data.objects if o.type == 'MESH'], key=lambda o: len(o.data.vertices))
|
|
me = body.data
|
|
names = [b.name for b in arm.data.bones]
|
|
print(f"BONES {len(names)}")
|
|
for key in ("Upperarm", "Forearm", "Hand", "Thigh", "Calf", "Foot", "Index", "Spine", "Head"):
|
|
hit = [n for n in names if key in n and n.startswith(("CC_Base_L", "CC_Base_"))][:4]
|
|
print(f" {key:10} -> {hit}")
|
|
|
|
n = len(me.vertices)
|
|
ev = np.empty(len(me.edges) * 2, dtype=np.int32); me.edges.foreach_get("vertices", ev)
|
|
ev = ev.reshape(-1, 2)
|
|
rest = np.empty(n * 3); me.vertices.foreach_get("co", rest); rest = rest.reshape(-1, 3)
|
|
rest_len = np.linalg.norm(rest[ev[:, 0]] - rest[ev[:, 1]], axis=1)
|
|
ok = rest_len > 1e-9
|
|
UNITM = 1.777 / (rest[:, 2].max() - rest[:, 2].min())
|
|
|
|
|
|
def deformed():
|
|
dg = bpy.context.evaluated_depsgraph_get()
|
|
ob = body.evaluated_get(dg)
|
|
m = ob.to_mesh()
|
|
a = np.empty(len(m.vertices) * 3)
|
|
m.vertices.foreach_get("co", a)
|
|
ob.to_mesh_clear()
|
|
return a.reshape(-1, 3)
|
|
|
|
|
|
def pose(spec):
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
arm.select_set(True)
|
|
bpy.context.view_layer.objects.active = arm
|
|
bpy.ops.object.mode_set(mode='POSE')
|
|
for pb in arm.pose.bones:
|
|
pb.rotation_mode = 'XYZ'
|
|
pb.rotation_euler = (0, 0, 0)
|
|
applied = 0
|
|
for frag, rot in spec:
|
|
for pb in arm.pose.bones:
|
|
# Twist bones are driven helpers that sit BETWEEN a joint and its child. Matching them
|
|
# by substring and rotating them by the parent's angle compounds the rotation and
|
|
# produces 14x edge stretch that looks like broken weights but is entirely self-
|
|
# inflicted — exclude them and pose only the primary joints.
|
|
if "Twist" in pb.name:
|
|
continue
|
|
if frag in pb.name:
|
|
pb.rotation_euler = Euler([math.radians(v) for v in rot])
|
|
applied += 1
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
bpy.context.view_layer.update()
|
|
return applied
|
|
|
|
|
|
POSES = [
|
|
("rest", []),
|
|
("arms_down", [("Upperarm", (0, 0, -55))]),
|
|
("elbows", [("Upperarm", (0, 0, -40)), ("Forearm", (0, 55, 0))]),
|
|
("fingers", [("Index2", (0, 0, 62)), ("Index3", (0, 0, 55)),
|
|
("Mid2", (0, 0, 62)), ("Mid3", (0, 0, 55)),
|
|
("Ring2", (0, 0, 62)), ("Ring3", (0, 0, 55)),
|
|
("Pinky2", (0, 0, 62)), ("Pinky3", (0, 0, 55))]),
|
|
("knees", [("Thigh", (35, 0, 0)), ("Calf", (-70, 0, 0))]),
|
|
("hips_twist", [("Spine", (0, 0, 25)), ("Thigh", (20, 0, 0))]),
|
|
]
|
|
|
|
scn = bpy.context.scene
|
|
scn.render.engine = 'BLENDER_EEVEE'
|
|
scn.render.resolution_x = scn.render.resolution_y = 1000
|
|
wd = bpy.data.worlds.new("w"); wd.use_nodes = True
|
|
wd.node_tree.nodes["Background"].inputs[0].default_value = (0.20, 0.20, 0.22, 1)
|
|
scn.world = wd
|
|
for r in (Euler((math.radians(58), 0, math.radians(-35))), Euler((math.radians(115), 0,
|
|
math.radians(150)))):
|
|
ld = bpy.data.lights.new("s", 'SUN'); ld.energy = 2.4; ld.use_shadow = False
|
|
lo = bpy.data.objects.new("s", ld); lo.rotation_euler = r
|
|
bpy.context.collection.objects.link(lo)
|
|
cam = bpy.data.cameras.new("c"); cam.lens = 70
|
|
cob = bpy.data.objects.new("c", cam); bpy.context.collection.objects.link(cob); scn.camera = cob
|
|
|
|
print("\n=== EDGE STRETCH (deformed length / rest length) ===")
|
|
print(f"{'pose':<12} {'bones':>6} {'p50':>7} {'p99':>7} {'p99.99':>8} {'max':>8} "
|
|
f"{'>2x':>6} {'>4x':>6}")
|
|
worst = {}
|
|
for name, spec in POSES:
|
|
nb = pose(spec)
|
|
D = deformed()
|
|
dl = np.linalg.norm(D[ev[:, 0]] - D[ev[:, 1]], axis=1)
|
|
r = dl[ok] / rest_len[ok]
|
|
worst[name] = float(r.max())
|
|
print(f"{name:<12} {nb:6d} {np.percentile(r,50):7.3f} {np.percentile(r,99):7.3f} "
|
|
f"{np.percentile(r,99.99):8.3f} {r.max():8.3f} {int((r>2).sum()):6d} "
|
|
f"{int((r>4).sum()):6d}")
|
|
# Explicit camera placement, copied from 09_beauty.py which frames this exact body correctly.
|
|
# A to_track_quat aim was tried here and put her off-frame entirely.
|
|
for view, loc, rot in (("", Vector((0.0, -2.05, 0.55)), Euler((math.radians(90), 0, 0))),
|
|
("_34", Vector((-1.35, -1.55, 0.55)),
|
|
Euler((math.radians(90), 0, math.radians(-41))))):
|
|
cob.location = loc
|
|
cob.rotation_euler = rot
|
|
scn.render.filepath = os.path.join(OUT, f"{name}{view}.png")
|
|
bpy.ops.render.render(write_still=True)
|
|
pose([])
|
|
print("\nverdict: skin stretches smoothly, so a healthy pose keeps max near p99.99.")
|
|
print("a lone edge far above the rest is a mis-weighted vertex, not a pose.")
|
|
print("POSE_QC_DONE")
|