MobileWorld-Diffusion / infer_mobilegui.py
xwk123's picture
Upload model checkpoint 4965
991e088 verified
Raw
History Blame Contribute Delete
13.6 kB
"""Inference for Qwen-Image-Edit-2511 fine-tuned on Mobile-GUI world-model data.
Loads the last 3 samples from the training metadata and runs them through
either the full checkpoint or the LoRA adapter (or both sequentially).
Usage:
python infer_mobilegui.py --mode full
python infer_mobilegui.py --mode lora
python infer_mobilegui.py --mode both # full first, then LoRA
python infer_mobilegui.py --mode both --num-steps 40 --seed 123
Run from any cwd; the script chdirs into DiffSynth-Studio so the base model
cache at ./models/Qwen/... resolves. Outputs go to --output-dir (default
alongside this script: ./infer_outputs/).
"""
import argparse
import gc
import json
import os
import re
import sys
from pathlib import Path
PROJECT = Path("/storage/ljx")
DIFFSYNTH = PROJECT / "repo" / "DiffSynth-Studio"
DATA_BASE = PROJECT / "data" / "Mobile-GUI-Worldmodel-SFT"
DEFAULT_METADATA = DATA_BASE / "metadata_qwen_edit.json"
DEFAULT_FULL_DIR = PROJECT / "models" / "train" / "Qwen-Image-Edit-2511_MobileGUI_full_v2"
DEFAULT_LORA_DIR = PROJECT / "models" / "train" / "Qwen-Image-Edit-2511_MobileGUI_lora_v2"
def pick_free_gpu(min_free_mib: int = 20000) -> int:
"""Return index of the GPU with the most free memory (via nvidia-smi).
Returns 0 as a last-resort fallback. Intended for pods that requested
multiple GPUs but only need one, to avoid hitting a device already
polluted by a non-k8s process.
"""
import subprocess
try:
out = subprocess.check_output(
["nvidia-smi",
"--query-gpu=index,memory.free",
"--format=csv,noheader,nounits"],
text=True, timeout=10)
except Exception as e:
print(f"[gpu-pick] nvidia-smi failed: {e}; fallback to 0")
return 0
rows = []
for line in out.strip().splitlines():
idx, free = [x.strip() for x in line.split(",")]
rows.append((int(idx), int(free)))
rows.sort(key=lambda r: -r[1])
print(f"[gpu-pick] nvidia-smi free MiB by index: {rows}")
if rows and rows[0][1] >= min_free_mib:
return rows[0][0]
print(f"[gpu-pick] no GPU has >= {min_free_mib} MiB free, "
f"using index {rows[0][0] if rows else 0} anyway")
return rows[0][0] if rows else 0
def latest_step_ckpt(ckpt_dir: Path) -> Path:
"""Pick the step-N.safetensors with the largest N."""
cands = []
for p in ckpt_dir.glob("step-*.safetensors"):
m = re.match(r"step-(\d+)\.safetensors$", p.name)
if m:
cands.append((int(m.group(1)), p))
if not cands:
raise FileNotFoundError(f"No step-*.safetensors in {ckpt_dir}")
cands.sort()
return cands[-1][1]
def target_hw(src_w: int, src_h: int, target_area: int = 1024 * 1024,
divisor: int = 32) -> tuple[int, int]:
"""Pick (h, w) matching source aspect with area ~= target_area, divisible."""
import math
ratio = src_w / src_h
w = math.sqrt(target_area * ratio)
h = w / ratio
w = max(divisor, round(w / divisor) * divisor)
h = max(divisor, round(h / divisor) * divisor)
return h, w
def load_samples(n: int = 3, where: str = "tail",
metadata: Path = DEFAULT_METADATA) -> list[dict]:
print(f"[load] reading metadata: {metadata}")
with open(metadata) as f:
data = json.load(f)
if where == "head":
print(f"[load] total records: {len(data)}; taking first {n}")
return data[:n]
print(f"[load] total records: {len(data)}; taking last {n}")
return data[-n:]
def make_pipe(low_vram: bool = False):
import torch
from diffsynth.pipelines.qwen_image import QwenImagePipeline, ModelConfig
extra = {}
if low_vram:
# CPU offload (not disk) so pipe.dit.load_state_dict(assign=True) still
# finds real tensors to replace. Each transformer block is paged to GPU
# for computation. ~40GB CPU RAM, ~2GB peak VRAM per block.
extra = dict(
offload_dtype=torch.bfloat16,
offload_device="cpu",
onload_dtype=torch.bfloat16,
onload_device="cpu",
preparing_dtype=torch.bfloat16,
preparing_device="cuda",
computation_dtype=torch.bfloat16,
computation_device="cuda",
)
print("[pipe] low-VRAM mode: bf16-on-cpu, bf16-compute-on-gpu")
print("[pipe] loading base Qwen-Image-Edit-2511 pipeline ...")
pipe = QwenImagePipeline.from_pretrained(
torch_dtype=torch.bfloat16,
device="cuda",
model_configs=[
ModelConfig(model_id="Qwen/Qwen-Image-Edit-2511",
origin_file_pattern="transformer/diffusion_pytorch_model*.safetensors",
**extra),
ModelConfig(model_id="Qwen/Qwen-Image",
origin_file_pattern="text_encoder/model*.safetensors",
**extra),
ModelConfig(model_id="Qwen/Qwen-Image",
origin_file_pattern="vae/diffusion_pytorch_model.safetensors",
**extra),
],
tokenizer_config=None,
processor_config=ModelConfig(model_id="Qwen/Qwen-Image-Edit",
origin_file_pattern="processor/"),
)
return pipe
def apply_full(pipe, ckpt_path: Path):
import torch
from diffsynth import load_state_dict
print(f"[full] loading checkpoint: {ckpt_path}")
sd = load_state_dict(str(ckpt_path))
# assign=True is needed when the base model has meta parameters (low_vram
# disk offload). strict=False tolerates wrapper prefixes. After assign,
# parameters live wherever the checkpoint lived — move back to the
# pipeline's compute device.
missing, unexpected = pipe.dit.load_state_dict(sd, strict=False, assign=True)
if missing:
print(f"[full] WARN missing keys: {len(missing)} (sample: {missing[:3]})")
if unexpected:
print(f"[full] WARN unexpected keys: {len(unexpected)} (sample: {unexpected[:3]})")
dev = getattr(pipe, "device", "cuda")
dtype = getattr(pipe, "torch_dtype", torch.bfloat16)
pipe.dit.to(device=dev, dtype=dtype)
print(f"[full] moved dit to device={dev} dtype={dtype}")
def apply_lora(pipe, ckpt_path: Path):
print(f"[lora] loading adapter: {ckpt_path}")
pipe.load_lora(pipe.dit, str(ckpt_path))
def run_mode(mode: str, samples: list[dict], out_dir: Path,
num_steps: int, seed: int, ckpt_override: Path | None,
low_vram: bool = False,
full_dir: Path = DEFAULT_FULL_DIR,
lora_dir: Path = DEFAULT_LORA_DIR):
from PIL import Image
if mode == "full":
ckpt = ckpt_override or latest_step_ckpt(full_dir)
elif mode == "lora":
ckpt = ckpt_override or latest_step_ckpt(lora_dir)
else:
raise ValueError(mode)
pipe = make_pipe(low_vram=low_vram)
if mode == "full":
apply_full(pipe, ckpt)
else:
apply_lora(pipe, ckpt)
mode_dir = out_dir / mode
mode_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "ckpt_used.txt").open("a").write(f"{mode}: {ckpt}\n")
for i, s in enumerate(samples):
prompt = s["prompt"]
in_path = DATA_BASE / s["edit_image"]
gt_path = DATA_BASE / s["image"]
print(f"\n[{mode}] sample {i}: in={in_path.name} gt={gt_path.name}")
print(f"[{mode}] prompt: {prompt[:120].replace(chr(10), ' ')}...")
src = Image.open(in_path).convert("RGB")
h, w = target_hw(src.size[0], src.size[1])
print(f"[{mode}] src {src.size} -> gen {w}x{h}")
out = pipe(
prompt=prompt,
edit_image=src,
seed=seed,
num_inference_steps=num_steps,
height=h, width=w,
zero_cond_t=True,
)
out_path = mode_dir / f"sample{i}_pred.png"
out.save(out_path)
print(f"[{mode}] saved {out_path}")
# Copy input/gt/prompt once (they're mode-independent).
shared_in = out_dir / f"sample{i}_input.png"
shared_gt = out_dir / f"sample{i}_gt.png"
if not shared_in.exists():
src.save(shared_in)
if not shared_gt.exists():
Image.open(gt_path).convert("RGB").save(shared_gt)
(out_dir / f"sample{i}_prompt.txt").write_text(prompt)
# Free GPU before next mode.
import torch
del pipe
gc.collect()
torch.cuda.empty_cache()
def make_grid(out_dir: Path, num: int, modes: list[str]):
from PIL import Image
print(f"\n[grid] building side-by-side comparison")
rows = []
for i in range(num):
cols = []
labels = []
for name in ["input", "gt"] + [f"{m}" for m in modes]:
if name in ("input", "gt"):
p = out_dir / f"sample{i}_{name}.png"
else:
p = out_dir / name / f"sample{i}_pred.png"
if p.exists():
cols.append(Image.open(p).convert("RGB"))
labels.append(name)
if not cols:
continue
target_h = 768
resized = []
for img in cols:
ratio = target_h / img.size[1]
resized.append(img.resize(
(int(img.size[0] * ratio), target_h), Image.LANCZOS))
row_w = sum(im.size[0] for im in resized) + 8 * (len(resized) - 1)
row = Image.new("RGB", (row_w, target_h + 30), (20, 20, 20))
x = 0
from PIL import ImageDraw, ImageFont
draw = ImageDraw.Draw(row)
try:
font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20)
except Exception:
font = ImageFont.load_default()
for img, lab in zip(resized, labels):
row.paste(img, (x, 30))
draw.text((x + 4, 4), f"[{lab}]", fill=(255, 255, 255), font=font)
x += img.size[0] + 8
rows.append(row)
if not rows:
print("[grid] nothing to stitch")
return
max_w = max(r.size[0] for r in rows)
total_h = sum(r.size[1] for r in rows) + 12 * (len(rows) - 1)
grid = Image.new("RGB", (max_w, total_h), (10, 10, 10))
y = 0
for r in rows:
grid.paste(r, (0, y))
y += r.size[1] + 12
grid_path = out_dir / "comparison.jpg"
grid.save(grid_path, quality=92)
print(f"[grid] saved {grid_path}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--mode", choices=["full", "lora", "both", "grid"],
default="both")
ap.add_argument("--num-samples", type=int, default=3)
ap.add_argument("--sample-from", choices=["head", "tail", "both"],
default="tail",
help="which slice of metadata: head / tail / both")
ap.add_argument("--num-steps", type=int, default=40)
ap.add_argument("--seed", type=int, default=123)
ap.add_argument("--output-dir", type=Path,
default=PROJECT / "infer_outputs")
ap.add_argument("--metadata", type=Path, default=DEFAULT_METADATA,
help="path to metadata_qwen_edit.json to sample prompts from")
ap.add_argument("--full-dir", type=Path, default=DEFAULT_FULL_DIR,
help="directory of full-finetune step-*.safetensors")
ap.add_argument("--lora-dir", type=Path, default=DEFAULT_LORA_DIR,
help="directory of LoRA step-*.safetensors")
ap.add_argument("--ckpt-full", type=Path, default=None,
help="override full checkpoint path")
ap.add_argument("--ckpt-lora", type=Path, default=None,
help="override LoRA adapter path")
ap.add_argument("--low-vram", action="store_true",
help="offload weights to CPU in fp8; ~20GB VRAM, slower")
args = ap.parse_args()
# Pin to a single least-loaded physical GPU before torch initialises.
if "CUDA_VISIBLE_DEVICES" not in os.environ or \
"," in os.environ.get("CUDA_VISIBLE_DEVICES", ""):
gpu = pick_free_gpu()
os.environ["CUDA_VISIBLE_DEVICES"] = str(gpu)
print(f"[init] CUDA_VISIBLE_DEVICES={gpu}")
os.chdir(DIFFSYNTH)
print(f"[init] cwd -> {os.getcwd()}")
args.output_dir.mkdir(parents=True, exist_ok=True)
modes_run = []
if args.mode in ("full", "both"):
modes_run.append("full")
if args.mode in ("lora", "both"):
modes_run.append("lora")
if args.sample_from == "both":
slices = [("head", args.output_dir / "head"),
("tail", args.output_dir / "tail")]
else:
slices = [(args.sample_from, args.output_dir)]
if modes_run:
# Load base pipe once per (slice, mode). We let run_mode handle pipe
# creation/teardown so checkpoint swapping stays isolated.
for slice_name, slice_dir in slices:
slice_dir.mkdir(parents=True, exist_ok=True)
samples = load_samples(args.num_samples, slice_name, args.metadata)
(slice_dir / "samples.json").write_text(
json.dumps(samples, indent=2, ensure_ascii=False))
for m in modes_run:
ckpt = args.ckpt_full if m == "full" else args.ckpt_lora
run_mode(m, samples, slice_dir, args.num_steps, args.seed,
ckpt, low_vram=args.low_vram,
full_dir=args.full_dir, lora_dir=args.lora_dir)
for _, slice_dir in slices:
make_grid(slice_dir, args.num_samples, ["full", "lora"])
if __name__ == "__main__":
main()