diff --git a/server.py b/server.py index c30edc6..b7433ef 100644 --- a/server.py +++ b/server.py @@ -31,6 +31,7 @@ EXTRA_BODIES = [os.path.expanduser('~/Documents/anatomy'), CACHE = os.path.join(ROOT, '.glbcache') BLENDER = os.environ.get('WG_BLENDER', '/Applications/Blender.app/Contents/MacOS/Blender') OPS = os.path.join(ROOT, 'blender_ops.py') +TOOLS = os.path.join(ROOT, 'tools') # reskin trio (NPCFACTORY) + the doll compositor (90sDJsim) # Creds come off disk, never off the command line. .env beside server.py (gitignored) holds the # CF Workers AI pair; MB_TOKEN lives in backnforth/.env — read it there rather than making every # launch remember `export MB_TOKEN=…` (without it the farm tiers silently die: no RMBG-2.0, no @@ -120,9 +121,13 @@ FLAT_PROMPT = ('product photo of a single {phrase} laid flat on a plain white ba 'slightly worn') -def run_blender(args, jid=None): - """One headless Blender op; stdout tail lands in the job log (when there is a job).""" - cmd = [BLENDER, '-b', '--python', OPS, '--'] + [str(a) for a in args] +def run_blender(args, jid=None, script=None): + """One headless Blender op; stdout tail lands in the job log (when there is a job). + + script= runs a different bpy file (the reskin trio lifted from NPCFACTORY) instead of + blender_ops.py. + """ + cmd = [BLENDER, '-b', '--python', script or OPS, '--'] + [str(a) for a in args] r = subprocess.run(cmd, capture_output=True, text=True, timeout=1800) tail = '\n'.join((r.stdout + r.stderr).strip().splitlines()[-12:]) if jid and jid in JOBS: @@ -318,6 +323,62 @@ class H(BaseHTTPRequestHandler): json.dump(rec, f, indent=2) return self.j({'ok': True, 'name': name}) + if u.path == '/api/reskin': # 3D tier: paint a garment onto the body's own UVs + # Sidesteps the fact that every TRELLIS body is unstructured with no shared topology — + # instead of fitting a cloth mesh, we repaint the atlas the body already has. Needs a + # single-mesh / single-UV / single-material body (multi-material rigs shard the bake). + if not MB_TOKEN: + return self.j({'error': 'reskin needs the farm image-edit operator — MB_TOKEN missing'}, 400) + b, g = body.get('body', ''), body.get('garment', '') + for p in (b, g): + if not allowed(p) or not os.path.isfile(p): + return self.j({'error': 'path outside library'}, 403) + desc = body.get('desc') or 'the garment in image 2' + jid = new_job('reskin', 'reskin ' + os.path.basename(b)) + + def fx(jid): + src = glb_of(b, jid) + name = slug(os.path.basename(b).rsplit('.', 1)[0]) + '--' + slug(os.path.basename(g).rsplit('.', 1)[0]) + wd = os.path.join(DIRS['gen'], '_reskin', name) + os.makedirs(wd, exist_ok=True) + JOBS[jid]['note'] = 'rendering bind-pose plates' + run_blender([src, wd], jid, script=os.path.join(TOOLS, 'render_plates.py')) + gid = mb_upload(g) + outs = {} + for side in ('front', 'back'): + plate = os.path.join(wd, f'base_{side}.png') + if not os.path.exists(plate): + raise RuntimeError(f'no {side} plate rendered') + JOBS[jid]['note'] = f'image-edit: dressing the {side} plate' + j = mb_req('/api/jobs', { + 'operator': 'mflux_image_edit', + 'asset_ids': [mb_upload(plate), gid], + 'params': {'prompt': f'put {desc} from image 2 onto the person in image 1. ' + 'Keep the exact same pose, body proportions, framing and ' + 'plain background. Full body visible, head to toe.'}}) + ji = j.get('id') or j.get('job_id') + mb_wait(ji, jid, timeout=900) + o = mb_outputs(ji) + if not o: + raise RuntimeError(f'image-edit returned nothing for the {side} plate') + edited = os.path.join(wd, f'edit_{side}.png') + mb_download(o[0], edited) + # the edit model redraws the figure at its own scale/offset; the projection bake + # assumes the original framing, so unaligned plates make the body sample background + aligned = os.path.join(wd, f'aligned_{side}.png') + subprocess.run(['python3', os.path.join(TOOLS, 'align_plate.py'), plate, edited, aligned], + capture_output=True, text=True, timeout=300) + outs[side] = aligned if os.path.exists(aligned) else edited + JOBS[jid]['note'] = 'baking onto the body atlas (Cycles)' + tgt = os.path.join(DIRS['garments'], name + '.reskin.png') + run_blender([src, outs['front'], outs['back'], tgt], jid, + script=os.path.join(TOOLS, 'bake_skin.py')) + if not os.path.exists(tgt): + raise RuntimeError('bake produced no texture') + return tgt + job_thread(jid, fx) + return self.j({'job': jid}) + if u.path == '/api/doll/gen': # 2D tier: prompt → staged paper-doll layer if not DOLL: return self.j({'error': '2D tier unavailable — needs pillow + numpy'}, 400) diff --git a/tools/bake_skin.py b/tools/bake_skin.py index f59fb5c..8635739 100644 --- a/tools/bake_skin.py +++ b/tools/bake_skin.py @@ -51,7 +51,42 @@ 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) -bsdf = node('ShaderNodeBsdfDiffuse'); nt.links.new(bsdf.inputs['Color'], mix.outputs[2]) +# --- 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) diff --git a/web/index.html b/web/index.html index b997d1a..9cd6953 100644 --- a/web/index.html +++ b/web/index.html @@ -149,6 +149,13 @@