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:
type-two 2026-07-24 16:47:26 +10:00
parent 3f6b1131cd
commit e3d1a0aa13
2 changed files with 134 additions and 35 deletions

126
server.py
View File

@ -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

View File

@ -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');