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>
100 lines
5.7 KiB
Python
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)
|