operator: qwen_layered_local — image -> N editable RGBA layers (Qwen-Image-Layered 20B, MLX)

Vendored mflux PR#302 fork (monster/Qwen-Image-Layered-MRP-MLX), tuned
defaults 20 steps/640/baked-q8 (243s m3ultra, 366s m1ultra). Baked-model
resolution via QWEN_LAYERED_MODEL env or per-box paths, HF+q8 fallback.
nodes.json (gitignored, machine-local) updated on m3 to route to m1 too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-19 22:06:05 +10:00
parent 8849438bce
commit 58a2a8177d
3 changed files with 117 additions and 0 deletions

25
scripts/install_qwen_layered.sh Executable file
View File

@ -0,0 +1,25 @@
#!/bin/bash
# Install the Qwen-Image-Layered MLX runtime (mflux PR#302 fork, vendored at
# our Gitea) into vendor/mflux-qwen-layered + a uv venv. Idempotent.
#
# Model weights are NOT fetched here. run.py resolves, in order:
# $QWEN_LAYERED_MODEL -> ~/qwen-layered/qwen-layered-q8 (m3)
# -> ~/qwen-layered-staging/qwen-layered-q8 (m1)
# -> HF Qwen/Qwen-Image-Layered with on-the-fly -q8 (54GB download).
# Bake a local q8 once per box: .venv/bin/mflux-save \
# --model Qwen/Qwen-Image-Layered --base-model qwen-image-layered \
# --quantize 8 --path ~/qwen-layered/qwen-layered-q8
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DEST="$ROOT/vendor/mflux-qwen-layered"
REPO="ssh://git@100.71.119.27:222/monster/Qwen-Image-Layered-MRP-MLX.git"
if [ ! -d "$DEST/.git" ]; then
git clone "$REPO" "$DEST"
else
echo "already cloned: $DEST"
fi
cd "$DEST"
/opt/homebrew/bin/uv venv .venv --python 3.12
/opt/homebrew/bin/uv pip install -q --python .venv/bin/python -e .
.venv/bin/mflux-generate-qwen-layered --help >/dev/null && echo "qwen-layered OK: $DEST"

View File

@ -0,0 +1,21 @@
{
"id": "qwen_layered_local",
"name": "Qwen-Image-Layered (local)",
"category": "image-edit",
"description": "Image → N editable RGBA layers (bg / subject / detail separation), fully local via the Qwen-Image-Layered 20B MLX port (mflux PR#302 fork). ~4 min on m3ultra, ~6 min on m1ultra at the tuned defaults (20 steps, 640, baked q8). The subject layer doubles as matting-grade background removal. Bake a local q8 model once per box (see scripts/install_qwen_layered.sh) or first run falls back to a 54GB HF download + on-the-fly quantization.",
"accepts": ["image"],
"produces": ["image"],
"resources": "gpu",
"entry": "run.py",
"python": "vendor/mflux-qwen-layered/.venv/bin/python",
"params_schema": {
"type": "object",
"properties": {
"layers": {"type": "integer", "default": 4, "description": "Number of RGBA layers to decompose into"},
"steps": {"type": "integer", "default": 20, "description": "Denoising steps (20 = tuned default; 50 = upstream default, ~2.9x slower, no visible gain on tested inputs)"},
"resolution": {"type": "integer", "enum": [640, 1024], "default": 640, "description": "Working resolution"},
"seed": {"type": "integer", "default": 0, "description": "Random seed"},
"prompt": {"type": "string", "default": "", "description": "Optional prompt to guide the decomposition"}
}
}
}

View File

@ -0,0 +1,71 @@
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
VENDOR = ROOT / "vendor" / "mflux-qwen-layered"
CLI = VENDOR / ".venv" / "bin" / "mflux-generate-qwen-layered"
ap = argparse.ArgumentParser()
ap.add_argument("--input", action="append", default=[])
ap.add_argument("--outdir", required=True)
ap.add_argument("--params", default="{}")
a = ap.parse_args()
p = json.loads(a.params)
if not a.input:
print("ERROR: no input image")
sys.exit(1)
if not CLI.exists():
print(f"ERROR: qwen-layered not installed at {VENDOR}. "
"Run scripts/install_qwen_layered.sh")
sys.exit(1)
# Baked-model resolution: env override, then per-box conventional paths,
# else fall back to the HF repo with on-the-fly q8 (slow first run: 54GB).
candidates = [os.environ.get("QWEN_LAYERED_MODEL", "")]
candidates += [str(Path.home() / "qwen-layered" / "qwen-layered-q8"),
str(Path.home() / "qwen-layered-staging" / "qwen-layered-q8")]
model_path = next((c for c in candidates if c and Path(c).is_dir()), None)
outdir = Path(a.outdir)
cmd = [
str(CLI), "--image", str(Path(a.input[0]).resolve()),
"--layers", str(p.get("layers", 4)),
"--steps", str(p.get("steps", 20)),
"--resolution", str(p.get("resolution", 640)),
"--seed", str(p.get("seed", 0)),
"--output-dir", str(outdir.resolve()),
]
if model_path:
cmd += ["--model-path", model_path]
print(f"using baked model: {model_path}", flush=True)
else:
cmd += ["-q", "8"]
print("no baked model found — HF fallback with on-the-fly q8 "
"(first run downloads 54GB)", flush=True)
if p.get("prompt"):
cmd += ["--prompt", str(p["prompt"])]
env = os.environ.copy()
env["HF_HUB_DISABLE_XET"] = "1"
print("+", " ".join(cmd), flush=True)
res = subprocess.run(cmd, cwd=str(VENDOR), stdout=sys.stdout,
stderr=subprocess.STDOUT, env=env)
if res.returncode != 0:
sys.exit(res.returncode)
layers = sorted(outdir.glob("layer_*.png"))
if not layers:
print("ERROR: qwen-layered produced no layer PNGs")
sys.exit(1)
stem = Path(a.input[0]).stem
outputs = [{"path": str(f), "name": f"{stem}_{f.stem}.png",
"meta": {"tool": "qwen_layered_local", "layer": i}}
for i, f in enumerate(layers)]
(outdir / "result.json").write_text(json.dumps({"outputs": outputs}))
print(f"done: {len(layers)} layers", flush=True)