A. PIPELINE FIX: gen_props.py trellis_mac -> trellis2_mlx, contract read from its
manifest.json. The two operators are NOT drop-in on output — trellis2_mlx writes
BOTH candidate.glb (raw) and candidate_pbr.glb (baked), so the old
sorted(rglob('*.glb'))[0] would have silently shipped untextured meshes. Added
_pick_glb() (result.json first) + the bg_remove_local cutout stage, fail-soft.
MEASURED: median 128.0s vs trellis_mac's 245.4s p50 = 1.92x. 5/5 first attempt,
0 retries against the 4-5% budget. One 489s tail outlier, no error — named.
B. AUDIO (1.1) — six PACK entries, +0.86MB, ZERO draws/tris, all originalby
construction: dog-bark-fence, mower-distant, radio-through-window, sprinkler,
roller-door, magpie-carol. Seeded via crc32(name); beds fold seamlessly
(wrap-jump <=0.18x internal step). Verified by spectrogram + band-energy, and
one defect found by LOOKING: the sprinkler's spray masked its own ticks and read
as a hose — rebalanced and re-rendered. Distance is spent in the SPECTRUM, not
the level, because encode()'s loudnorm undoes a quiet render.
C. GROUNDS (1.2) — gen_skins.py had NO ground template; added one with its own
outdoor style prefix (the house STYLE says "warm fluorescent", an interior cue).
4 new 512^2 square tiles. Review's 5.7s is 9.7s here — same per-pixel, mine are
1.78x the pixels. Found: gravel/reddust are selected by nothing partly because
their `use` values name no slot ground.js:177 has.
D. MESHES — 3 shipped (magpie 894 tris, paling_fence 1121, water_tank 500), all
GLB-law clean. 2 FAILED and were DELETED: hills_hoist + tv_aerial. Thin wire
returns as thousands of disconnected fragments, so collapse floors at 250k/89k
AND a remesh coarse enough for 600 tris erases the pole. Lane B's boxMats
primitives cost +0 draws and are strictly better. The magpie's geometry passes
but TRELLIS crushed its white nape/wing to black — traced to the operator's bake
(the cutout and our decimation are both exonerated); flagged, not hidden.
E. REUSE — all 9 idle assets re-measured; the review's "one line each" holds for
only 5. furniture.js's extractGLB keeps only the FIRST material, so park_bench/
coffee_table/box_crate render in material #1 and bench_wood (0 materials) falls
to grey. Nothing was regenerated that already existed.
The 3 GLBs are staged, NOT published: a depot push is outward-facing and had no
in-session authorization. Held at _r38_results.PENDING_PUBLISH.json; publish +
rename + rebuild lands them. Gate green: validate_manifest 0 errors / 0 warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
235 lines
12 KiB
Python
235 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""PROCITY hero props — free, on-device (round-3 Fable path: MODELBEAST, not fal.ai).
|
|
|
|
Three local stages, all on the M3 Ultra GPU, zero cloud cost:
|
|
1. concept image flux_local (FLUX.2-klein-4B) text->object photo ~6-9s
|
|
2. cutout bg_remove_local (RMBG-2.0) photo->RGBA subject ~5-10s
|
|
3. mesh trellis2_mlx (TRELLIS.2-4B MLX) image->GLB+PBR ~2-3 min (1024_cascade)
|
|
then pipeline/normalize.py (house GLB law) + thumbnail + manifest.
|
|
|
|
R38 PIPELINE FIX — the operator swap (docs/REVIEW_CITY_PATTERNS.md §2, measured on the farm):
|
|
this script used to hardcode `trellis_mac` (p50 245.4s, 33 jobs/7d). The farm's workhorse is
|
|
**`trellis2_mlx`** (384 jobs/7d, p50 134.6s, better PBR bake) — a ~2x throughput gain. The two
|
|
operators are NOT drop-in on output: trellis_mac writes one `*.glb`, trellis2_mlx writes BOTH
|
|
`candidate.glb` (raw, untextured) and `candidate_pbr.glb` (the baked one) into the same tree,
|
|
so a naive `sorted(rglob('*.glb'))[0]` silently picks the untextured mesh. We read `result.json`
|
|
(the operator's own declared output) and fall back to `candidate_pbr.glb` by name. `--legacy-trellis`
|
|
keeps the old operator available for an A/B.
|
|
|
|
Prop list + prompts come from pipeline/meshgod_batch.json (the same gap list that was going to
|
|
fal.ai). cash-register is skipped — vintage-cash-register.glb already lives on the 3GOD depot.
|
|
|
|
python3 pipeline/gen_props.py --dry-run # list props + what's done, no GPU
|
|
python3 pipeline/gen_props.py --concepts # stage 1 for all -> .genprops/<name>.png
|
|
python3 pipeline/gen_props.py --concepts --only glass-case
|
|
python3 pipeline/gen_props.py --mesh --only glass-case # stages 2+3 for one (concept must exist)
|
|
python3 pipeline/gen_props.py --mesh # stages 2+3 for every prop with a concept, no glb
|
|
python3 pipeline/gen_props.py --mesh --no-cutout # skip RMBG (concept already has alpha)
|
|
python3 pipeline/gen_props.py --mesh --legacy-trellis # the old trellis_mac path (A/B only)
|
|
python3 pipeline/gen_props.py --draft --only glass-case # sf3d seconds-fast draft (iterate concept)
|
|
|
|
Resumable: skips a stage whose output already exists in .genprops/. Eyeball .genprops/*.png (concepts)
|
|
and the rendered GLBs before normalizing. Everything here is OPTIONAL at runtime — every prop has an
|
|
in-engine primitive fallback (see MESHGOD_BATCH.md _primitives_instead), so nothing blocks on it.
|
|
"""
|
|
import json, os, sys, subprocess, shutil, time
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
RAW = os.path.join(ROOT, "pipeline", ".genprops")
|
|
BATCH = os.path.join(ROOT, "pipeline", "meshgod_batch.json")
|
|
os.makedirs(RAW, exist_ok=True)
|
|
|
|
MB = os.environ.get("MB_HOME", os.path.expanduser("~/Documents/MODELBEAST"))
|
|
FLUX_PY = os.path.join(MB, "venvs/mflux/bin/python")
|
|
FLUX_RUN = os.path.join(MB, "server/operators/flux_local/run.py")
|
|
# R38: trellis2_mlx is the farm's workhorse (p50 134.6s vs trellis_mac's 245.4s). Its operator
|
|
# run.py is stdlib-only and resolves the fork's own .venv internally, so the outer interpreter
|
|
# just has to exist; we use the fork venv for symmetry with the legacy line below.
|
|
TRELLIS2_PY = os.path.join(MB, "vendor/trellis2-mlx/.venv/bin/python")
|
|
TRELLIS2_RUN = os.path.join(MB, "server/operators/trellis2_mlx/run.py")
|
|
TRELLIS_PY = os.path.join(MB, "vendor/trellis-mac/.venv/bin/python") # legacy (--legacy-trellis)
|
|
TRELLIS_RUN = os.path.join(MB, "server/operators/trellis_mac/run.py")
|
|
RMBG_PY = os.path.join(MB, "venvs/rmbg/bin/python")
|
|
RMBG_RUN = os.path.join(MB, "server/operators/bg_remove_local/run.py")
|
|
SF3D_PY = os.path.join(MB, "venvs/sf3d/bin/python")
|
|
SF3D_RUN = os.path.join(MB, "server/operators/sf3d/run.py")
|
|
|
|
# Clean product-shot framing so TRELLIS/sf3d (which segment the subject) get a whole, isolated
|
|
# object on a plain backdrop — NOT the faded op-shop facade look. Period-appropriate but neutral.
|
|
CONCEPT = ("Clean studio product photograph of {v}. Single object, centred, entire object in "
|
|
"frame, three-quarter view, plain seamless pale-grey backdrop, soft even lighting, "
|
|
"sharp focus, no text, no people, no other objects, 1990s Australian period-correct.")
|
|
|
|
|
|
def props():
|
|
data = json.load(open(BATCH))
|
|
out = []
|
|
for a in data["assets"]:
|
|
# cash-register: reuse the depot's vintage-cash-register.glb (round-3 reuse check)
|
|
if a["name"] == "cash-register":
|
|
continue
|
|
out.append(a)
|
|
return out
|
|
|
|
|
|
def concept_path(name):
|
|
return os.path.join(RAW, f"{name}.png")
|
|
|
|
|
|
def glb_path(name):
|
|
return os.path.join(RAW, f"{name}.glb")
|
|
|
|
|
|
def cutout_path(name):
|
|
return os.path.join(RAW, f"{name}.cut.png")
|
|
|
|
|
|
def gen_cutout(name):
|
|
"""concept photo -> RGBA subject cutout (bg_remove_local / RMBG-2.0). The mesh operators
|
|
segment internally, but feeding a clean alpha measurably improves thin-feature retention and
|
|
it is the path the R38 review measured. Fail-soft: caller falls back to the raw concept."""
|
|
src = concept_path(name)
|
|
outdir = os.path.join(RAW, "_x_" + name)
|
|
os.makedirs(outdir, exist_ok=True)
|
|
params = json.dumps({"resolution": 1024, "background": "transparent"})
|
|
r = subprocess.run([RMBG_PY, RMBG_RUN, "--input", src, "--outdir", outdir, "--params", params],
|
|
capture_output=True, text=True, timeout=600)
|
|
cut = os.path.join(outdir, os.path.splitext(os.path.basename(src))[0] + "_cutout.png")
|
|
if not os.path.exists(cut):
|
|
shutil.rmtree(outdir, ignore_errors=True)
|
|
raise RuntimeError((r.stderr or r.stdout or "no cutout")[-200:])
|
|
dst = cutout_path(name)
|
|
shutil.move(cut, dst)
|
|
shutil.rmtree(outdir, ignore_errors=True)
|
|
return dst
|
|
|
|
|
|
def _pick_glb(outdir):
|
|
"""The operator-output contract, made explicit. trellis2_mlx emits BOTH candidate.glb (raw)
|
|
and candidate_pbr.glb (baked) — alphabetical order picks the WRONG one. Trust result.json
|
|
first (each operator declares its own output there), then the _pbr name, then anything."""
|
|
rj = os.path.join(outdir, "result.json")
|
|
if os.path.exists(rj):
|
|
try:
|
|
outs = json.load(open(rj)).get("outputs") or []
|
|
for o in outs:
|
|
p = o.get("path", "")
|
|
p = p if os.path.isabs(p) else os.path.join(outdir, p)
|
|
if p.endswith(".glb") and os.path.exists(p):
|
|
return p
|
|
except Exception:
|
|
pass
|
|
glbs = []
|
|
for dp, _, fs in os.walk(outdir):
|
|
glbs += [os.path.join(dp, f) for f in fs if f.endswith(".glb")]
|
|
if not glbs:
|
|
return None
|
|
pbr = [g for g in glbs if os.path.basename(g) == "candidate_pbr.glb"]
|
|
return (pbr or sorted(glbs))[0]
|
|
|
|
|
|
def gen_concept(a, seed=3):
|
|
name, prompt = a["name"], a["prompt"]
|
|
outdir = os.path.join(RAW, "_c_" + name)
|
|
os.makedirs(outdir, exist_ok=True)
|
|
params = json.dumps({"prompt": CONCEPT.format(v=prompt), "model": "flux2-klein-4b",
|
|
"steps": 4, "width": 1024, "height": 1024, "seed": seed})
|
|
r = subprocess.run([FLUX_PY, FLUX_RUN, "--outdir", outdir, "--params", params],
|
|
capture_output=True, text=True, timeout=300)
|
|
pngs = [p for p in os.listdir(outdir) if p.endswith(".png")]
|
|
if not pngs:
|
|
raise RuntimeError((r.stderr or r.stdout or "no png")[-200:])
|
|
shutil.move(os.path.join(outdir, pngs[0]), concept_path(name))
|
|
shutil.rmtree(outdir, ignore_errors=True)
|
|
return concept_path(name)
|
|
|
|
|
|
def gen_mesh(a, draft=False, legacy=False, cutout=True):
|
|
"""concept image -> GLB. trellis2_mlx 1024_cascade by default (R38 swap); sf3d for a fast
|
|
draft; --legacy-trellis for the old trellis_mac operator."""
|
|
name = a["name"]
|
|
cpath = concept_path(name)
|
|
if not os.path.exists(cpath):
|
|
raise RuntimeError(f"no concept image for {name} — run --concepts first")
|
|
if cutout and not draft:
|
|
if os.path.exists(cutout_path(name)):
|
|
cpath = cutout_path(name)
|
|
else:
|
|
try:
|
|
cpath = gen_cutout(name)
|
|
except Exception as e: # fail-soft: the mesh op segments anyway
|
|
print(f" [cutout] {name} skipped: {str(e)[:90]}")
|
|
outdir = os.path.join(RAW, "_m_" + name)
|
|
os.makedirs(outdir, exist_ok=True)
|
|
if draft:
|
|
params = json.dumps({"device": "mps", "texture_resolution": 1024, "foreground_ratio": 0.85})
|
|
cmd = [SF3D_PY, SF3D_RUN, "--input", cpath, "--outdir", outdir, "--params", params]
|
|
tmo = 600
|
|
elif legacy:
|
|
params = json.dumps({"pipeline_type": "1024_cascade", "texture_size": 1024, "seed": 0})
|
|
cmd = [TRELLIS_PY, TRELLIS_RUN, "--input", cpath, "--outdir", outdir, "--params", params]
|
|
tmo = 1800
|
|
else:
|
|
# trellis2_mlx params_schema (manifest.json, verified R38): pipeline_type / seed / steps /
|
|
# baker / texture_size / max_bake_faces / alpha_mode. metal baker = UV PBR (meshgod-grade);
|
|
# 500k bake cap = fal parity and M3-Ultra-verified; opaque alpha kills the speckle veil.
|
|
params = json.dumps({"pipeline_type": "1024_cascade", "texture_size": 1024, "seed": 0,
|
|
"baker": "metal", "max_bake_faces": 500000, "alpha_mode": "opaque"})
|
|
cmd = [TRELLIS2_PY, TRELLIS2_RUN, "--input", cpath, "--outdir", outdir, "--params", params]
|
|
tmo = 1800
|
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=tmo)
|
|
src = _pick_glb(outdir)
|
|
if not src:
|
|
raise RuntimeError((r.stderr or r.stdout or "no glb")[-240:])
|
|
dst = glb_path(name)
|
|
shutil.move(src, dst)
|
|
shutil.rmtree(outdir, ignore_errors=True)
|
|
return dst
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if "--batch" in sys.argv: # override the asset list (e.g. gig instruments)
|
|
BATCH = os.path.abspath(sys.argv[sys.argv.index("--batch") + 1])
|
|
only = sys.argv[sys.argv.index("--only") + 1] if "--only" in sys.argv else ""
|
|
draft = "--draft" in sys.argv
|
|
legacy = "--legacy-trellis" in sys.argv
|
|
cutout = "--no-cutout" not in sys.argv
|
|
todo = [a for a in props() if not only or only in a["name"]]
|
|
|
|
if "--dry-run" in sys.argv:
|
|
print(f"{len(todo)} props (cash-register reused from depot):")
|
|
for a in todo:
|
|
c = "concept✓" if os.path.exists(concept_path(a["name"])) else "concept·"
|
|
g = "glb✓" if os.path.exists(glb_path(a["name"])) else "glb·"
|
|
print(f" {a['name']:16s} {c} {g} h={a['height_m']}m for={a['for']}")
|
|
sys.exit(0)
|
|
|
|
do_concepts = "--concepts" in sys.argv or (not "--mesh" in sys.argv and not draft)
|
|
do_mesh = "--mesh" in sys.argv or draft
|
|
|
|
if do_concepts:
|
|
for a in todo:
|
|
if os.path.exists(concept_path(a["name"])):
|
|
print(f"[concept] {a['name']} — exists, skip"); continue
|
|
t0 = time.time()
|
|
try:
|
|
gen_concept(a)
|
|
print(f"[concept] {a['name']} — {round(time.time()-t0,1)}s")
|
|
except Exception as e:
|
|
print(f"[concept] {a['name']} FAILED: {e}")
|
|
|
|
if do_mesh:
|
|
tool = ("sf3d-draft" if draft else
|
|
("trellis_mac 1024_cascade" if legacy else "trellis2_mlx 1024_cascade"))
|
|
for a in todo:
|
|
if not draft and os.path.exists(glb_path(a["name"])):
|
|
print(f"[mesh] {a['name']} — glb exists, skip"); continue
|
|
t0 = time.time()
|
|
try:
|
|
dst = gen_mesh(a, draft=draft, legacy=legacy, cutout=cutout)
|
|
kb = os.path.getsize(dst) // 1024
|
|
print(f"[mesh:{tool}] {a['name']} — {round(time.time()-t0,1)}s ({kb}KB)")
|
|
except Exception as e:
|
|
print(f"[mesh:{tool}] {a['name']} FAILED: {e}")
|
|
print("done. Eyeball .genprops/*.png (concepts) + rendered GLBs before normalize.")
|