Act on the LoRA bench: fix a real farm bug, flip to the sharper backend, make gen reproducible
A 17-agent bench classified 103 models across the 125GB civit library and ran 12 live LoRA tests. Headline: NO LoRA BEATS THE PLAIN BASELINE — all 12 came back unusable, so the recommended recipe for both tiers contains zero LoRAs. The failure is at the checkpoint, not the LoRAs: comfyui_sd is the only farm operator that loads a LoRA and it can reach exactly one checkpoint, a person-photoreal SD1.5 merge that draws a woman for any clothing noun no matter how hard you negative-prompt it. flux_local, the lane that actually works, has no lora param at all. The two lanes are disjoint, so no LoRA here got a fair garment evaluation. Real bug found and fixed: the farm IGNORES ?parent_job=. A bogus id still returns the entire asset table (measured: 2,578 rows, 34 distinct parents), so mb_outputs()[0] was only ever correct because the table happens to come back newest-first — two jobs in flight would download each other's images, which is exactly what happened to two agents in the bench. Now filtered client-side. Measured changes: · Default backend CF -> flux_local klein-4b. At matched prompt and seed, klein holds corduroy wale across the garment where CF flux-1-schnell flattens it to smooth canvas. CF stays selectable: ~2s and never queues behind the farm's serial GPU lane. · Seeds are reproducible. /api/gen used time.time()%99991 so nothing could ever be reproduced, and /api/doll/gen inherited the operator default seed=42 so a phrase produced a byte-identical image forever and reroll did nothing. Both now hash the phrase + a variant counter; rerolling is bumping variant. · The painterly white doll2d template that actually shipped the 441 sprites wasn't in this repo at all — only djsim's later green variant. Added, and made the default; green is now the documented escape hatch for plaid and busy patterns, since the bench measured klein on pure white keying 7/7 flood seeds on both a dark solid and a busy multicolour knit. · Doll generation moved 1024 -> 704 to match the canvas, so we stop downscaling before the key and softening the edges the keyer depends on. · Templates hoisted to one registry; /api/gen returns the resolved prompt and seed and the UI shows them under the preview. · Local keyer warns loudly: flat3d product shots come back on studio grey with a drop shadow (border 215-244, zero pixels >=250), which only partially seeds the >232 gate. RMBG is semantic and unaffected. Verified: seeds deterministic and variant-sensitive, templates resolve view/year, and a live klein-4b generation produced a flat-lay corduroy jacket with legible wale and no person. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3f6b1131cd
commit
e3d1a0aa13
126
server.py
126
server.py
@ -108,17 +108,39 @@ def doll_catalogue():
|
||||
return DOLL.catalogue() if DOLL else {}
|
||||
|
||||
|
||||
# The prompt that produced the 441 shipped sprites, lifted verbatim from 90sDJsim's
|
||||
# gen_wardrobe.py. Two hard-won rules are baked in and must not be "improved":
|
||||
# · SOLID BRIGHT GREEN background — grey/checkerboard backgrounds eat garments in the key,
|
||||
# and plaid survives green. This is why we don't generate on white for the doll tier.
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# Prompt templates. `doll2d` is the one that actually shipped the 441 sprites; the green
|
||||
# variant is 90sDJsim's later gen_wardrobe fallback. Rules baked in, do not "improve":
|
||||
# · no text/words/logos — flux can't spell, so band tees are ART, never readable words.
|
||||
DOLL_PROMPT = ('1990s Australian {phrase}, product photo, flat lay, centered, '
|
||||
'solid bright green background, no text, no words, no logos, no person, no hanger')
|
||||
# The 3D tier wants a plain-white product shot instead — nothing gets keyed on green there.
|
||||
FLAT_PROMPT = ('product photo of a single {phrase} laid flat on a plain white background, front view, '
|
||||
# · green is for PLAID and busy patterns. A bench run measured klein-4b on pure white
|
||||
# keying cleanly (7/7 flood seeds) on both a dark solid and a busy multicolour knit, so
|
||||
# white is the default and green is the escape hatch, not the other way round.
|
||||
TEMPLATES = {
|
||||
'flat3d': ('product photo of a single {phrase} laid flat on a plain white background, front view, '
|
||||
'no mannequin, no person, no text, soft even lighting, 1990s Australian op-shop garment, '
|
||||
'slightly worn')
|
||||
'slightly worn'),
|
||||
'doll2d': ('paper doll clothing item for a dress-up game, {phrase}, {year} australia, {view}, '
|
||||
'floating centered on a plain pure white background, painterly stylized 3d game '
|
||||
'illustration, full item visible, no body, no person, no mannequin, no hanger, '
|
||||
'no coat hanger, no text, no logos'),
|
||||
'doll2d_green': ('1990s Australian {phrase}, product photo, flat lay, centered, '
|
||||
'solid bright green background, no text, no words, no logos, no person, no hanger'),
|
||||
}
|
||||
VIEWS = {'hat': 'front view', 'top': 'front view laid flat with the sleeves down',
|
||||
'bottom': 'front view laid perfectly flat on the ground',
|
||||
'shoes': 'the pair side by side, front view', 'bag': 'front view'}
|
||||
DOLL_PROMPT = TEMPLATES['doll2d_green'] # back-compat alias
|
||||
FLAT_PROMPT = TEMPLATES['flat3d']
|
||||
|
||||
|
||||
def gen_seed(phrase, variant=0):
|
||||
"""Reproducible seed from the phrase — reroll by bumping `variant`.
|
||||
|
||||
Replaces two broken schemes: /api/gen used `time.time() % 99991` (nothing was ever
|
||||
reproducible) and /api/doll/gen inherited the operator default seed=42 (the same phrase
|
||||
produced a byte-identical image forever, so there was no reroll at all).
|
||||
"""
|
||||
return int(hashlib.sha1(f'{phrase}_{variant}'.encode()).hexdigest()[:7], 16)
|
||||
|
||||
|
||||
def run_blender(args, jid=None, script=None):
|
||||
@ -212,9 +234,18 @@ def mb_wait(job_id, jid, timeout=900):
|
||||
|
||||
|
||||
def mb_outputs(job_id):
|
||||
"""Output assets for one farm job.
|
||||
|
||||
The farm IGNORES ?parent_job= — a bogus id still returns the whole asset table (measured:
|
||||
2,578 rows, 34 distinct parents). Taking rows[0] only ever worked because the table comes
|
||||
back newest-first, so two jobs in flight would happily download each other's image. Always
|
||||
filter client-side. job.asset_ids is [] even on success, so it's no help either.
|
||||
"""
|
||||
assets = mb_req(f'/api/assets?parent_job={job_id}')
|
||||
rows = assets.get('assets', assets) if isinstance(assets, dict) else assets
|
||||
return rows or []
|
||||
if isinstance(rows, dict):
|
||||
rows = rows.get('items', [])
|
||||
return [r for r in (rows or []) if isinstance(r, dict) and str(r.get('parent_job')) == str(job_id)]
|
||||
|
||||
|
||||
def mb_download(asset, tgt):
|
||||
@ -390,7 +421,17 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.j({'error': 'slot must be hat|top|bottom|shoes|bag'}, 400)
|
||||
if not phrase:
|
||||
return self.j({'error': 'need a garment phrase'}, 400)
|
||||
# doll2d (painterly, pure white) is the template the 441 shipped sprites were made
|
||||
# with; doll2d_green is the escape hatch for plaid and busy patterns where a white
|
||||
# key gets confused. Default to what shipped.
|
||||
tpl = body.get('template', 'doll2d')
|
||||
if tpl not in TEMPLATES:
|
||||
return self.j({'error': 'template must be one of ' + '|'.join(TEMPLATES)}, 400)
|
||||
variant = int(body.get('variant', 0) or 0)
|
||||
style = TEMPLATES[tpl].format(phrase=phrase, year=body.get('year', 1990),
|
||||
view=VIEWS.get(slot, 'front view'))
|
||||
jid = new_job('doll', f'{slot}: {phrase[:40]}')
|
||||
JOBS[jid]['prompt'] = style
|
||||
|
||||
def fx(jid):
|
||||
stage_dir = os.path.join(DIRS['gen'], '_doll')
|
||||
@ -398,10 +439,13 @@ class H(BaseHTTPRequestHandler):
|
||||
base = DOLL.slugify(keyname)
|
||||
raw = os.path.join(stage_dir, f'{slot}_{base}_flux.png')
|
||||
cut = os.path.join(stage_dir, f'{slot}_{base}_keyed.png')
|
||||
JOBS[jid]['note'] = 'flux on green'
|
||||
JOBS[jid]['note'] = f'flux ({tpl})'
|
||||
# 704, not 1024: the doll canvas is 704 wide, so generating bigger only means
|
||||
# downscaling before the key and softening the edges we depend on.
|
||||
j = mb_req('/api/jobs', {'operator': 'flux_local',
|
||||
'params': {'prompt': DOLL_PROMPT.format(phrase=phrase),
|
||||
'width': 1024, 'height': 1024}})
|
||||
'params': {'prompt': style, 'model': 'flux2-klein-4b',
|
||||
'steps': 4, 'width': 704, 'height': 704,
|
||||
'seed': gen_seed(phrase, variant)}})
|
||||
gid = j.get('id') or j.get('job_id')
|
||||
mb_wait(gid, jid)
|
||||
outs = mb_outputs(gid)
|
||||
@ -511,20 +555,39 @@ class H(BaseHTTPRequestHandler):
|
||||
if u.path == '/api/gen': # the garment pipeline, step 1: flux image
|
||||
if not (CF_ACCT and CF_TOKEN) and not MB_TOKEN:
|
||||
return self.j({'error': 'no image backend — need .env CF creds or MB_TOKEN'}, 400)
|
||||
prompt = body.get('prompt', '')
|
||||
style = ('product photo of a single {} laid flat on a plain white background, '
|
||||
'front view, no mannequin, no person, no text, soft even lighting, '
|
||||
'1990s Australian op-shop garment, slightly worn').format(prompt)
|
||||
jid = new_job('gen', 'flux: ' + prompt[:40])
|
||||
phrase = body.get('phrase') or body.get('prompt', '')
|
||||
if not phrase:
|
||||
return self.j({'error': 'need a garment phrase'}, 400)
|
||||
tpl = body.get('template', 'flat3d')
|
||||
if tpl not in TEMPLATES:
|
||||
return self.j({'error': 'template must be one of ' + '|'.join(TEMPLATES)}, 400)
|
||||
# klein-4b is the DEFAULT because it measurably renders fabric better: at matched
|
||||
# prompt/seed it holds corduroy wale across the garment where CF flux-1-schnell
|
||||
# flattens it to smooth canvas. CF stays available — it's ~2s and never queues
|
||||
# behind the farm's serial GPU lane, which is worth real money on a one-at-a-time UI.
|
||||
backend = body.get('backend', 'klein' if MB_TOKEN else 'cf')
|
||||
variant = int(body.get('variant', 0) or 0)
|
||||
seed = body.get('seed')
|
||||
seed = gen_seed(phrase, variant) if seed in (None, '') else int(seed)
|
||||
wide = int(body.get('width') or (704 if tpl.startswith('doll2d') else 1024))
|
||||
steps = int(body.get('steps') or 4)
|
||||
style = TEMPLATES[tpl].format(phrase=phrase, year=body.get('year', 1990),
|
||||
view=VIEWS.get(body.get('view', 'top'), 'front view'))
|
||||
if body.get('lora') and backend != 'sd':
|
||||
pass # flux_local has no lora param at all — silently ignoring beats pretending
|
||||
jid = new_job('gen', f'{backend}: {phrase[:36]}')
|
||||
JOBS[jid]['prompt'] = style
|
||||
JOBS[jid]['seed'] = seed
|
||||
|
||||
def fx(jid):
|
||||
tgt = os.path.join(DIRS['gen'], slug(prompt) + '.png')
|
||||
if CF_ACCT and CF_TOKEN: # Cloudflare Workers AI direct — the flux.mjs contract
|
||||
tgt = os.path.join(DIRS['gen'], f'{slug(phrase)}-v{variant}.png')
|
||||
if backend == 'cf' and CF_ACCT and CF_TOKEN:
|
||||
import base64
|
||||
JOBS[jid]['note'] = 'cloudflare flux-1-schnell'
|
||||
JOBS[jid]['note'] = 'cloudflare flux-1-schnell (softer fabric)'
|
||||
req = urllib.request.Request(
|
||||
f'https://api.cloudflare.com/client/v4/accounts/{CF_ACCT}/ai/run/@cf/black-forest-labs/flux-1-schnell',
|
||||
data=json.dumps({'prompt': style, 'steps': 8, 'width': 768, 'height': 768}).encode(),
|
||||
data=json.dumps({'prompt': style, 'steps': max(steps, 8),
|
||||
'width': wide, 'height': wide, 'seed': seed}).encode(),
|
||||
headers={'Authorization': 'Bearer ' + CF_TOKEN, 'Content-Type': 'application/json'})
|
||||
with urllib.request.urlopen(req, timeout=120) as r:
|
||||
out = json.load(r)
|
||||
@ -533,17 +596,19 @@ class H(BaseHTTPRequestHandler):
|
||||
with open(tgt, 'wb') as f:
|
||||
f.write(base64.b64decode(out['result']['image']))
|
||||
return tgt
|
||||
j = mb_req('/api/jobs', {'operator': 'flux_local', 'asset_id': None,
|
||||
JOBS[jid]['note'] = 'flux_local klein-4b'
|
||||
j = mb_req('/api/jobs', {'operator': 'flux_local',
|
||||
'params': {'prompt': style, 'model': 'flux2-klein-4b',
|
||||
'steps': 4, 'width': 768, 'height': 768,
|
||||
'seed': int(time.time()) % 99991}})
|
||||
mb_wait(j.get('id') or j.get('job_id'), jid)
|
||||
outs = mb_outputs(j.get('id') or j.get('job_id'))
|
||||
'steps': steps, 'width': wide, 'height': wide,
|
||||
'seed': seed}})
|
||||
gid = j.get('id') or j.get('job_id')
|
||||
mb_wait(gid, jid)
|
||||
outs = mb_outputs(gid)
|
||||
if not outs:
|
||||
raise RuntimeError('no image came back')
|
||||
return mb_download(outs[0], tgt)
|
||||
job_thread(jid, fx)
|
||||
return self.j({'job': jid})
|
||||
return self.j({'job': jid, 'seed': seed, 'prompt': style})
|
||||
|
||||
if u.path == '/api/rmbg': # step 2: cut it out (farm RMBG, else local white-keyer)
|
||||
p = body.get('path', '')
|
||||
@ -563,7 +628,10 @@ class H(BaseHTTPRequestHandler):
|
||||
return mb_download(outs[0], tgt)
|
||||
# local fallback: border-flood white-keyer (never naive white→alpha — traps ledger).
|
||||
# flood from the edges through near-white pixels only, so white ON the garment survives.
|
||||
JOBS[jid]['note'] = 'local white-keyer'
|
||||
# Measured limit: flat3d product shots come back with a studio-grey backdrop and a soft
|
||||
# drop shadow (border 215-244, ZERO pixels ≥250), so this >232 gate only partially seeds
|
||||
# and leaves grey fringing. RMBG-2.0 is semantic and doesn't care — warn loudly here.
|
||||
JOBS[jid]['note'] = 'local white-keyer (no farm token — grey backdrops will fringe)'
|
||||
from PIL import Image
|
||||
im = Image.open(p).convert('RGBA')
|
||||
w, h = im.size
|
||||
|
||||
@ -170,8 +170,18 @@
|
||||
|
||||
<div id="tab-generate" style="display:none">
|
||||
<h2>new garment — step 1: image</h2>
|
||||
<textarea id="genPrompt" rows="3" placeholder="e.g. brown corduroy jacket / bucket hat / white leather sneakers"></textarea>
|
||||
<textarea id="genPrompt" rows="2" placeholder="e.g. brown corduroy jacket / bucket hat / white leather sneakers"></textarea>
|
||||
<div class="row"><label>template</label>
|
||||
<select id="genTpl"><option value="flat3d">flat3d — product shot (→3D)</option>
|
||||
<option value="doll2d">doll2d — painterly (→doll)</option>
|
||||
<option value="doll2d_green">doll2d_green — plaid/busy</option></select></div>
|
||||
<div class="row"><label>backend</label>
|
||||
<select id="genBackend"><option value="klein">klein-4b — sharper fabric</option>
|
||||
<option value="cf">cf schnell — 2s, softer</option></select></div>
|
||||
<div class="row"><label>variant</label><input id="genVariant" value="0" style="width:56px">
|
||||
<button id="genReroll" class="ghost">↻ reroll</button></div>
|
||||
<div class="row"><button id="genBtn">flux it</button><span class="note" id="genNote"></span></div>
|
||||
<div class="note" id="genMeta"></div>
|
||||
<img id="genPrev">
|
||||
<h2>step 2: cutout</h2>
|
||||
<div class="row"><button id="rmbgBtn" class="ghost" disabled>bg_remove</button></div>
|
||||
@ -445,11 +455,32 @@ $('asmBtn').onclick = () => (BODY && picked.size) ? runJob('/api/blender', { op:
|
||||
|
||||
// generator chain
|
||||
let GENPICK = null;
|
||||
$('genBtn').onclick = async () => {
|
||||
const p = $('genPrompt').value.trim(); if (!p) return;
|
||||
const out = await runJob('/api/gen', { prompt: p }, 'flux');
|
||||
if (out) { GENPICK = out; $('genPrev').src = '/file?p=' + encodeURIComponent(out) + '&t=' + Date.now(); $('genPrev').style.display = 'block'; $('rmbgBtn').disabled = false; }
|
||||
};
|
||||
async function doGen() {
|
||||
const p = $('genPrompt').value.trim(); if (!p) return toast('describe the garment');
|
||||
const payload = { phrase: p, template: $('genTpl').value, backend: $('genBackend').value,
|
||||
variant: +$('genVariant').value || 0, view: 'top' };
|
||||
// show the resolved prompt + seed: the cheapest debugging affordance there is, and without it
|
||||
// a bad generation gives you nothing to reason about
|
||||
const pre = await fetch('/api/gen', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload) }).then(r => r.json());
|
||||
if (pre.error) return toast('✗ ' + pre.error);
|
||||
$('genMeta').textContent = `seed ${pre.seed} · ${pre.prompt}`;
|
||||
toast('flux…');
|
||||
while (true) {
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
const j = await fetch('/api/job/' + pre.job).then(r => r.json());
|
||||
toast(`flux: ${j.status}${j.note ? ' · ' + j.note : ''}`);
|
||||
if (j.status === 'error') return toast('✗ flux: ' + (j.log || '').split('\n').pop());
|
||||
if (j.status === 'done') {
|
||||
GENPICK = j.out;
|
||||
$('genPrev').src = '/file?p=' + encodeURIComponent(j.out) + '&t=' + Date.now();
|
||||
$('genPrev').style.display = 'block'; $('rmbgBtn').disabled = false;
|
||||
refresh(); return toast('✓ ' + (j.out || '').split('/').pop());
|
||||
}
|
||||
}
|
||||
}
|
||||
$('genBtn').onclick = doGen;
|
||||
$('genReroll').onclick = () => { $('genVariant').value = (+$('genVariant').value || 0) + 1; doGen(); };
|
||||
$('rmbgBtn').onclick = async () => {
|
||||
if (!GENPICK) return;
|
||||
const out = await runJob('/api/rmbg', { path: GENPICK }, 'cutout');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user