wardrobegod/tools/bake_skin.py
type-two cfca6708ab Phase 2: reskin engine — dress the nude TRELLIS bodies without fitting anything
Every anatomy body is an independent TRELLIS reconstruction: no shared topology, no vertex
correspondence, its own UV atlas. That kills garment portability — a fitted mesh can't move
between bodies. Reskin sidesteps the whole problem by repainting the atlas the body already
has, so it works on unstructured meshes and needs no rig.

Chain (/api/reskin), all local except one farm call:
  render_plates.py  -> bind-pose front/back ortho plates, EEVEE, 1024, transparent
  mflux_image_edit  -> "put <garment> from image 2 onto the person in image 1", per plate
  align_plate.py    -> re-fit the edited figure to the original bbox. The edit model redraws
                       at its own scale/offset, and the projection bake assumes the original
                       framing, so skipping this makes the body sample background.
  bake_skin.py      -> Cycles DIFFUSE projection onto the mesh's real UVs, front/back split
                       on normal.Y
Costume swapping in the browser is just a material.map change, so pre-baked outfits toggle
instantly with no re-bake; the bare map is retained so it's reversible.

Fixed a real defect in the inherited baker: planar front/back projection has no idea a head
is round, so side-facing skull polygons sampled whatever front-plate pixel sat at their x/z
and painted a ghost second face down the side of the head (clearly visible on phrtt2). Since
this tier repaints CLOTHES, the bake now keeps the body's own texture above a neck-height
cutoff (default 0.87 of body height, -1 to disable), sampling it through the mesh's real UVs.

Verified end-to-end on phrtt2 (1 mesh / 1 UVMap / 1 material / 4,364 tris — the reskin class):
farm try-on produced a convincing corduroy jacket holding the exact T-pose, and the bake put
it on the model. Known ceiling, unchanged and by design: the silhouette does not move, so this
sells tight and printed garments and reads wrong for a heavy coat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:22:38 +10:00

100 lines
5.7 KiB
Python

import bpy, sys, mathutils
src, front, back, out = sys.argv[-4], sys.argv[-3], sys.argv[-2], sys.argv[-1]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=src)
for o in bpy.data.objects:
if o.animation_data: o.animation_data_clear()
if o.type == 'ARMATURE':
for pb in o.pose.bones:
pb.location=(0,0,0); pb.rotation_quaternion=(1,0,0,0); pb.scale=(1,1,1)
bpy.context.view_layer.update()
meshes = [o for o in bpy.data.objects if o.type == 'MESH' and 'Body' in o.name] or [o for o in bpy.data.objects if o.type == 'MESH']
m = max(meshes, key=lambda o: len(o.data.vertices)) # dominant mesh, not a helper marker
mn = mathutils.Vector((1e9,)*3); mx = mathutils.Vector((-1e9,)*3)
for v in m.bound_box:
w = m.matrix_world @ mathutils.Vector(v)
mn = mathutils.Vector(map(min, mn, w)); mx = mathutils.Vector(map(max, mx, w))
c = (mn+mx)/2; h = (mx-mn).z; scale = h*1.1
imgF = bpy.data.images.load(front); imgB = bpy.data.images.load(back)
tgt = bpy.data.images.new('butcher_skin', 1024, 1024, alpha=False)
mat = bpy.data.materials.new('bakeproj'); mat.use_nodes = True
nt = mat.node_tree; nt.nodes.clear()
def node(t, **kw):
n = nt.nodes.new(t)
for k, v in kw.items(): setattr(n, k, v)
return n
geo = node('ShaderNodeNewGeometry')
sep = node('ShaderNodeSeparateXYZ'); nt.links.new(sep.inputs[0], geo.outputs['Position'])
def axis_map(out_sock, center, flip=False):
sub = node('ShaderNodeMath', operation='SUBTRACT'); sub.inputs[1].default_value = center
nt.links.new(sub.inputs[0], out_sock)
div = node('ShaderNodeMath', operation='DIVIDE'); div.inputs[1].default_value = scale
nt.links.new(div.inputs[0], sub.outputs[0])
if flip:
mul = node('ShaderNodeMath', operation='MULTIPLY'); mul.inputs[1].default_value = -1
nt.links.new(mul.inputs[0], div.outputs[0]); last = mul
else: last = div
add = node('ShaderNodeMath', operation='ADD'); add.inputs[1].default_value = 0.5
nt.links.new(add.inputs[0], last.outputs[0])
return add.outputs[0]
uF = axis_map(sep.outputs['X'], c.x); vv = axis_map(sep.outputs['Z'], c.z)
uB = axis_map(sep.outputs['X'], c.x, flip=True)
cmbF = node('ShaderNodeCombineXYZ'); nt.links.new(cmbF.inputs[0], uF); nt.links.new(cmbF.inputs[1], vv)
cmbB = node('ShaderNodeCombineXYZ'); nt.links.new(cmbB.inputs[0], uB); nt.links.new(cmbB.inputs[1], vv)
texF = node('ShaderNodeTexImage'); texF.image = imgF; texF.extension = 'EXTEND'
texB = node('ShaderNodeTexImage'); texB.image = imgB; texB.extension = 'EXTEND'
nt.links.new(texF.inputs[0], cmbF.outputs[0]); nt.links.new(texB.inputs[0], cmbB.outputs[0])
sepN = node('ShaderNodeSeparateXYZ'); nt.links.new(sepN.inputs[0], geo.outputs['Normal'])
lt = node('ShaderNodeMath', operation='LESS_THAN'); lt.inputs[1].default_value = 0.0
nt.links.new(lt.inputs[0], sepN.outputs['Y']) # n.y < 0 = front-facing
mix = node('ShaderNodeMix', data_type='RGBA')
nt.links.new(mix.inputs['Factor'], lt.outputs[0])
nt.links.new(mix.inputs[6], texB.outputs['Color']) # A = back
nt.links.new(mix.inputs[7], texF.outputs['Color']) # B = front (factor 1 when n.y<0)
# --- keep the original head -------------------------------------------------------------
# Front/back planar projection has no idea a head is round: side-facing skull polygons sample
# whatever front-plate pixel sits at their x/z, which paints a second ghost face down the side
# of the head. We're repainting CLOTHES, so above the neck we just keep the body's own texture.
# Cutoff is a fraction of body height measured from the feet (~0.87 ≈ chin on a standing figure);
# pass -1 to disable and bake the whole body.
KEEP_HEAD_ABOVE = float(sys.argv[-5]) if len(sys.argv) >= 6 and sys.argv[-5].replace('.', '', 1).replace('-', '', 1).isdigit() else 0.87
orig_tex = None
for slot in (m.data.materials[0] if m.data.materials else None,):
if slot and slot.use_nodes:
for n in slot.node_tree.nodes:
if n.type == 'TEX_IMAGE' and n.image:
orig_tex = n.image
break
if KEEP_HEAD_ABOVE > 0 and orig_tex is not None:
neck_z = mn.z + (mx.z - mn.z) * KEEP_HEAD_ABOVE
# original texture sampled through the mesh's REAL UVs (not the projection)
uvmap = node('ShaderNodeUVMap')
if m.data.uv_layers:
uvmap.uv_map = m.data.uv_layers[0].name
texO = node('ShaderNodeTexImage'); texO.image = orig_tex
nt.links.new(texO.inputs[0], uvmap.outputs[0])
above = node('ShaderNodeMath', operation='GREATER_THAN'); above.inputs[1].default_value = neck_z
nt.links.new(above.inputs[0], sep.outputs['Z'])
headmix = node('ShaderNodeMix', data_type='RGBA')
nt.links.new(headmix.inputs['Factor'], above.outputs[0])
nt.links.new(headmix.inputs[6], mix.outputs[2]) # below neck: the projected garment
nt.links.new(headmix.inputs[7], texO.outputs['Color']) # above neck: the untouched original
colour_out = headmix.outputs[2]
print(f'keeping original head above z={neck_z:.3f} ({KEEP_HEAD_ABOVE:.2f} of height)')
else:
colour_out = mix.outputs[2]
print('baking whole body (no head preserve)')
bsdf = node('ShaderNodeBsdfDiffuse'); nt.links.new(bsdf.inputs['Color'], colour_out)
outn = node('ShaderNodeOutputMaterial'); nt.links.new(outn.inputs['Surface'], bsdf.outputs[0])
baketex = node('ShaderNodeTexImage'); baketex.image = tgt; nt.nodes.active = baketex
m.data.materials.clear(); m.data.materials.append(mat)
sc = bpy.context.scene; sc.render.engine = 'CYCLES'
sc.cycles.samples = 16; sc.cycles.device = 'CPU'
bpy.ops.object.select_all(action='DESELECT'); m.select_set(True)
bpy.context.view_layer.objects.active = m
bpy.ops.object.bake(type='DIFFUSE', pass_filter={'COLOR'}, use_clear=True, margin=8)
tgt.filepath_raw = out; tgt.file_format = 'PNG'; tgt.save()
print('baked ->', out)