#!/usr/bin/env python3 """ Layer decomposition inference — Qwen-Image-Layered + GRPO LoRA. Decomposes a source image into ordered RGBA layers (background + objects). ============================================================================ RECOMMENDED INFERENCE SETTINGS *** USE THESE *** ============================================================================ Sampler : Heun (2nd order) <- the only sampler here; always used Steps : 50 <- --steps 50 (default) CFG : 1.0 (disabled) <- --guidance-scale 1.0 (default) Size : 640 px <- --size 640 (default) Layers : 4 <- --num-layers 4 (default) All published results were produced with exactly these settings, and they are the defaults below. Lowering the step count or raising CFG noticeably degrades the decomposition. Note 50 Heun steps == ~100 model evaluations (Heun is 2nd order: two forward passes per step) — that is expected, not a bug. ============================================================================ Usage: python decompose.py --input photo.png --output out/ python decompose.py --input images/ --output out/ --transparent Output per image: out//source.png resized input out//composite.png layers recomposited (sanity check vs source) out//layer_0.png background (inpainted behind the objects) out//layer_1..N.png object layers, back-to-front """ import argparse import os from pathlib import Path import numpy as np import torch from PIL import Image IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff"} # Qwen-Image prompt template (must match training/inference used for the LoRA) PROMPT_TEMPLATE = ( "<|im_start|>system\nDescribe the image by detailing the color, shape, size, " "texture, quantity, text, spatial relationships of the objects and background:" "<|im_end|>\n<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n" ) PROMPT_TEMPLATE_DROP_IDX = 34 DEFAULT_PROMPT = "a clean, well composed image" VAE_SCALE_FACTOR = 8 # --------------------------------------------------------------------------- # Latent helpers # --------------------------------------------------------------------------- def rgb_to_rgba(image): b, _, h, w = image.shape alpha = torch.ones(b, 1, h, w, device=image.device, dtype=image.dtype) return torch.cat([image, alpha], dim=1) def normalize_latents(latents, vae): mean = torch.tensor(vae.config.latents_mean).view(1, -1, 1, 1, 1).to(latents.device, latents.dtype) std = torch.tensor(vae.config.latents_std).view(1, -1, 1, 1, 1).to(latents.device, latents.dtype) return (latents - mean) / std def denormalize_latents(latents, vae): mean = torch.tensor(vae.config.latents_mean).view(1, -1, 1, 1, 1).to(latents.device, latents.dtype) inv_std = (1.0 / torch.tensor(vae.config.latents_std)).view(1, -1, 1, 1, 1).to(latents.device, latents.dtype) return latents / inv_std + mean def pack_latents(latents, batch_size, num_channels, height, width, num_frames): latents = latents.view(batch_size, num_frames, num_channels, height // 2, 2, width // 2, 2) latents = latents.permute(0, 1, 3, 5, 2, 4, 6) return latents.reshape(batch_size, num_frames * (height // 2) * (width // 2), num_channels * 4) def unpack_latents(latents, height, width, num_layers, vae_scale_factor=VAE_SCALE_FACTOR): batch_size, _, channels = latents.shape frames = num_layers + 1 h = 2 * (int(height) // (vae_scale_factor * 2)) w = 2 * (int(width) // (vae_scale_factor * 2)) latents = latents.view(batch_size, frames, h // 2, w // 2, channels // 4, 2, 2) latents = latents.permute(0, 1, 4, 2, 5, 3, 6) latents = latents.reshape(batch_size, frames, channels // 4, h, w) return latents.permute(0, 2, 1, 3, 4) @torch.no_grad() def encode_condition_image(vae, image): """RGB image tensor in [-1,1], (B,3,H,W) -> packed condition latents.""" b = image.shape[0] vae_input_ch = getattr(vae.config, "input_channels", 3) if vae_input_ch == 4 and image.shape[1] == 3: image = rgb_to_rgba(image) elif vae_input_ch == 3 and image.shape[1] == 4: image = image[:, :3] image_5d = image.unsqueeze(2).to(dtype=vae.dtype) dist = vae.encode(image_5d) if hasattr(dist, "latent_dist"): latents = dist.latent_dist.mode() elif hasattr(dist, "mode"): latents = dist.mode() else: latents = dist latents = normalize_latents(latents, vae) z_dim, lh, lw = latents.shape[1], latents.shape[3], latents.shape[4] latents = latents.permute(0, 2, 1, 3, 4) return pack_latents(latents, b, z_dim, lh, lw, 1).to(dtype=torch.bfloat16) @torch.no_grad() def decode_layers(vae, latents, height, width, num_layers): b = latents.shape[0] unpacked = unpack_latents(latents, height, width, num_layers) unpacked = denormalize_latents(unpacked, vae) layer_latents = unpacked[:, :, 1:].permute(0, 2, 1, 3, 4) # drop base frame _, _, c, h, w = layer_latents.shape decoded = vae.decode(layer_latents.reshape(b * num_layers, c, 1, h, w).to(dtype=vae.dtype), return_dict=False)[0] decoded = decoded.squeeze(2).float().clamp(-1.0, 1.0) _, c_out, h_out, w_out = decoded.shape return decoded.reshape(b, num_layers, c_out, h_out, w_out) def composite_layers(layers): """Porter-Duff 'over', back-to-front.""" if layers.shape[2] == 3: return layers.mean(dim=1) result = torch.zeros_like(layers[:, 0, :3]) for i in range(layers.shape[1]): rgb = layers[:, i, :3] alpha = (layers[:, i, 3:4] + 1.0) / 2.0 result = rgb * alpha + result * (1.0 - alpha) return result def tensor_to_pil(t, mode="RGB"): arr = ((t.clamp(-1, 1) + 1) / 2 * 255).byte().permute(1, 2, 0).cpu().numpy() return Image.fromarray(arr, mode=mode) def layer_to_pil_white_bg(layer): if layer.shape[0] == 4: rgb, alpha = layer[:3], (layer[3:4] + 1.0) / 2.0 return tensor_to_pil(rgb * alpha + torch.ones_like(rgb) * (1.0 - alpha)) return tensor_to_pil(layer) def layer_to_pil_rgba(layer): layer = layer.clamp(-1, 1).float() rgba = torch.cat([(layer[:3] + 1) / 2, (layer[3:4] + 1) / 2], dim=0) arr = (rgba * 255).byte().permute(1, 2, 0).cpu().numpy() return Image.fromarray(arr, mode="RGBA") # --------------------------------------------------------------------------- # Denoising — Heun (2nd order) # --------------------------------------------------------------------------- def transformer_forward(transformer, hidden_states, timestep, encoder_hidden_states, img_shapes, encoder_hidden_states_mask=None, additional_t_cond=None): return transformer( hidden_states=hidden_states, timestep=timestep / 1000, encoder_hidden_states=encoder_hidden_states, encoder_hidden_states_mask=encoder_hidden_states_mask, img_shapes=img_shapes, guidance=None, additional_t_cond=additional_t_cond, return_dict=False, )[0] def build_img_shapes(num_layers, packed_h, packed_w): # (num_layers + 1) generated frames + 1 condition-image entry return [[*[(1, packed_h, packed_w) for _ in range(num_layers + 1)], (1, packed_h, packed_w)]] @torch.no_grad() def denoise(transformer, scheduler, latents, timesteps, prompt_embeds, prompt_mask, img_shapes, condition_latents, guidance_scale, neg_embeds=None, neg_mask=None, additional_t_cond=None): """Heun's method (2nd order) over the scheduler's sigmas.""" gen_seq_len = latents.shape[1] do_cfg = guidance_scale > 1.0 and neg_embeds is not None def velocity(lat, t_val): ts = t_val.expand(1).to(lat.dtype) model_input = lat if condition_latents is None else torch.cat([lat, condition_latents], dim=1) v = transformer_forward(transformer, model_input, ts, prompt_embeds, img_shapes, prompt_mask, additional_t_cond)[:, :gen_seq_len] if do_cfg: v_neg = transformer_forward(transformer, model_input, ts, neg_embeds, img_shapes, neg_mask, additional_t_cond)[:, :gen_seq_len] v = v_neg + guidance_scale * (v - v_neg) return v sigmas = scheduler.sigmas for i, t in enumerate(timesteps): sigma = sigmas[i] sigma_next = sigmas[i + 1] if i + 1 < len(sigmas) else torch.tensor(0.0, device=t.device) dt = sigma_next - sigma v1 = velocity(latents, t) latents_mid = latents + dt * v1 if sigma_next > 0: # Heun corrector v2 = velocity(latents_mid, sigma_next * 1000) latents = latents + dt * 0.5 * (v1 + v2) else: latents = latents_mid return latents # --------------------------------------------------------------------------- # Pipeline # --------------------------------------------------------------------------- def compute_aspect_resize(orig_w, orig_h, max_size): """Keep aspect ratio, max dim = max_size, both dims divisible by 16.""" scale = max_size / max(orig_w, orig_h) return (max(int(round(orig_w * scale / 16)) * 16, 16), max(int(round(orig_h * scale / 16)) * 16, 16)) def collect_images(input_path): p = Path(input_path) if p.is_file(): return [p] if p.is_dir(): return sorted(f for f in p.iterdir() if f.suffix.lower() in IMAGE_EXTS) raise FileNotFoundError(f"input not found: {input_path}") def main(): ap = argparse.ArgumentParser( description="Layer decomposition (Heun / 50 steps / cfg 1.0 recommended).", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) ap.add_argument("--input", required=True, help="image file or directory of images") ap.add_argument("--output", required=True, help="output directory") ap.add_argument("--lora", default=str(Path(__file__).parent / "checkpoint-600"), help="path to the LoRA adapter directory") ap.add_argument("--base-model", default="Qwen/Qwen-Image-Layered", help="base model (HuggingFace id or local path)") # --- recommended settings (defaults) --- ap.add_argument("--steps", type=int, default=50, help="Heun steps (RECOMMENDED: 50)") ap.add_argument("--guidance-scale", type=float, default=1.0, help="CFG (RECOMMENDED: 1.0 = off)") ap.add_argument("--num-layers", type=int, default=4) ap.add_argument("--size", type=int, default=640, help="max image dimension") # --- misc --- ap.add_argument("--prompt", default=DEFAULT_PROMPT) ap.add_argument("--seed", type=int, default=42) ap.add_argument("--transparent", action="store_true", help="save layers as RGBA with real alpha (for compositing) " "instead of composited onto white") ap.add_argument("--device", default="cuda") ap.add_argument("--cache-dir", default=None, help="HuggingFace cache directory") args = ap.parse_args() if args.steps != 50 or args.guidance_scale != 1.0: print(f"WARNING: using steps={args.steps}, cfg={args.guidance_scale}. " f"Recommended is steps=50, cfg=1.0 — results may be worse.\n") device = torch.device(args.device) images = collect_images(args.input) if not images: raise SystemExit(f"no images found in {args.input}") os.makedirs(args.output, exist_ok=True) print(f"{len(images)} image(s) | Heun, {args.steps} steps, cfg {args.guidance_scale}, " f"{args.size}px, {args.num_layers} layers") # --- load base model ----------------------------------------------- print(f"Loading base model: {args.base_model}") from diffusers import DiffusionPipeline pipe = DiffusionPipeline.from_pretrained( args.base_model, torch_dtype=torch.bfloat16, trust_remote_code=True, cache_dir=args.cache_dir, ) transformer = pipe.transformer.to(device).eval() vae = pipe.vae.to(device).eval() scheduler = pipe.scheduler tokenizer = getattr(pipe, "tokenizer", None) text_encoder = getattr(pipe, "text_encoder", None) del pipe transformer.requires_grad_(False) vae.requires_grad_(False) # --- apply LoRA ------------------------------------------------------ print(f"Loading LoRA: {args.lora}") from peft import PeftModel transformer = PeftModel.from_pretrained(transformer, args.lora) transformer.eval() in_channels = getattr(transformer.config, "in_channels", 64) additional_t_cond = torch.zeros(1, dtype=torch.long, device=device) # --- prompt encoding ------------------------------------------------- if text_encoder is None or tokenizer is None: raise SystemExit("base model has no text encoder/tokenizer") text_encoder = text_encoder.to(device).eval() text_encoder.requires_grad_(False) @torch.no_grad() def encode_prompt(text): tokens = tokenizer([PROMPT_TEMPLATE.format(text)], padding=True, return_tensors="pt").to(text_encoder.device) out = text_encoder(input_ids=tokens.input_ids, attention_mask=tokens.attention_mask, output_hidden_states=True) hidden = out.hidden_states[-1] mask_bool = tokens.attention_mask.bool() lengths = mask_bool.sum(dim=1) chunks = torch.split(hidden[mask_bool], lengths.tolist(), dim=0) chunks = [c[PROMPT_TEMPLATE_DROP_IDX:] for c in chunks] # drop system prefix max_len = max(c.size(0) for c in chunks) embeds = torch.stack([torch.cat([c, c.new_zeros(max_len - c.size(0), c.size(1))]) for c in chunks]) mask = torch.stack([ torch.cat([torch.ones(c.size(0), dtype=torch.long, device=c.device), torch.zeros(max_len - c.size(0), dtype=torch.long, device=c.device)]) for c in chunks ]) return embeds.to(dtype=torch.bfloat16, device=device), mask.to(device) prompt_embeds, prompt_mask = encode_prompt(args.prompt) neg_embeds, neg_mask = encode_prompt("") # scheduler may or may not accept sigmas/mu import inspect supports_sigmas = "sigmas" in set(inspect.signature(scheduler.set_timesteps).parameters) # --- per-image loop -------------------------------------------------- for idx, img_path in enumerate(images): name = img_path.stem out_dir = Path(args.output) / name out_dir.mkdir(parents=True, exist_ok=True) print(f"[{idx + 1}/{len(images)}] {img_path.name}", flush=True) image = Image.open(img_path).convert("RGB") tw, th = compute_aspect_resize(*image.size, args.size) source = image.resize((tw, th), Image.LANCZOS) packed_h = (th // VAE_SCALE_FACTOR) // 2 packed_w = (tw // VAE_SCALE_FACTOR) // 2 img_t = torch.from_numpy(np.array(source)).permute(2, 0, 1).float() / 127.5 - 1.0 img_t = img_t.unsqueeze(0).to(device) condition_latents = encode_condition_image(vae, img_t) # dynamic-shift mu from the condition sequence length mu = (condition_latents.shape[1] / (256 * 256 / 16 / 16)) ** 0.5 if supports_sigmas: scheduler.set_timesteps(sigmas=np.linspace(1.0, 0, args.steps + 1)[:-1], device=device, mu=mu) else: scheduler.set_timesteps(num_inference_steps=args.steps, device=device) img_shapes = build_img_shapes(args.num_layers, packed_h, packed_w) seq_len = (args.num_layers + 1) * packed_h * packed_w gen = torch.Generator(device=device).manual_seed(args.seed + idx) latents = torch.randn(1, seq_len, in_channels, device=device, dtype=torch.bfloat16, generator=gen) latents = denoise(transformer, scheduler, latents, scheduler.timesteps, prompt_embeds, prompt_mask, img_shapes, condition_latents, args.guidance_scale, neg_embeds, neg_mask, additional_t_cond) decoded = decode_layers(vae, latents, th, tw, args.num_layers) composite = composite_layers(decoded) source.save(out_dir / "source.png") tensor_to_pil(composite[0]).save(out_dir / "composite.png") for li in range(args.num_layers): layer = decoded[0, li] pil = layer_to_pil_rgba(layer) if args.transparent else layer_to_pil_white_bg(layer) pil.save(out_dir / f"layer_{li}.png") print(f"\nDone -> {args.output}//{{source,composite,layer_0..{args.num_layers - 1}}}.png") if __name__ == "__main__": main()