| """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: |
| |
| |
| |
| 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)) |
| |
| |
| |
| |
| 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}") |
|
|
| |
| 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) |
|
|
| |
| 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() |
|
|
| |
| 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: |
| |
| |
| 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() |
|
|