Add vnasset edit command
Qwen Image Edit pipeline with FP8 Safetensors loading. Uses init_empty_weights for memory-efficient 40GB model loading. bf16 dtype to avoid ROCm crashes; falls back to math SDPA.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
"""VNAsset — Visual Novel Asset Pipeline CLI."""
|
"""VNAsset — Visual Novel Asset Pipeline CLI."""
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from .edit import edit
|
||||||
from .generate import generate
|
from .generate import generate
|
||||||
|
|
||||||
|
|
||||||
@@ -46,3 +47,29 @@ def generate_cmd(checkpoint, prompt, negative_prompt, width, height, steps, cfg,
|
|||||||
seed=seed,
|
seed=seed,
|
||||||
output_path=output,
|
output_path=output,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
@click.option("--model", required=True, help="Path to Qwen Image Edit diffusion model (.safetensors)")
|
||||||
|
@click.option("--input", "input_path", required=True, help="Input image to edit")
|
||||||
|
@click.option("--prompt", required=True, help="Edit instruction")
|
||||||
|
@click.option("--steps", default=20, type=int, help="Inference steps (4 for turbo)")
|
||||||
|
@click.option("--cfg", default=4.0, type=float, help="CFG scale (1.0 for turbo)")
|
||||||
|
@click.option(
|
||||||
|
"--seed", default="random", callback=_parse_seed,
|
||||||
|
help="RNG seed (integer or 'random')",
|
||||||
|
)
|
||||||
|
@click.option("--lora", "lora_path", default=None, help="Path to LoRA weights (.safetensors)")
|
||||||
|
@click.option("--output", default="output.png", help="Output image path")
|
||||||
|
def edit_cmd(model, input_path, prompt, steps, cfg, seed, lora_path, output):
|
||||||
|
"""Edit an image using Qwen Image Edit."""
|
||||||
|
edit(
|
||||||
|
model_path=model,
|
||||||
|
input_path=input_path,
|
||||||
|
prompt=prompt,
|
||||||
|
steps=steps,
|
||||||
|
cfg=cfg,
|
||||||
|
seed=seed,
|
||||||
|
lora_path=lora_path,
|
||||||
|
output_path=output,
|
||||||
|
)
|
||||||
|
|||||||
131
vnassets/edit.py
Normal file
131
vnassets/edit.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""Qwen Image Edit — image-to-image editing."""
|
||||||
|
import gc
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import safetensors.torch
|
||||||
|
import torch
|
||||||
|
from accelerate import init_empty_weights
|
||||||
|
from PIL import Image
|
||||||
|
from diffusers import QwenImageEditPlusPipeline, FlowMatchEulerDiscreteScheduler
|
||||||
|
from diffusers.models.autoencoders import AutoencoderKLQwenImage
|
||||||
|
from diffusers.models.transformers import QwenImageTransformer2DModel
|
||||||
|
from transformers import Qwen2_5_VLForConditionalGeneration, Qwen2Tokenizer, Qwen2VLProcessor
|
||||||
|
|
||||||
|
|
||||||
|
TEXT_ENCODER_ID = "Qwen/Qwen2.5-VL-7B-Instruct"
|
||||||
|
VAE_ID = "Qwen/Qwen-Image"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_transformer(path: str, dtype: torch.dtype) -> QwenImageTransformer2DModel:
|
||||||
|
"""Load Qwen Image Edit transformer from a single FP8 safetensors file.
|
||||||
|
|
||||||
|
Uses init_empty_weights and incremental conversion to keep peak memory
|
||||||
|
manageable. The model is 20B parameters (20 GB FP8, 40 GB BF16)."""
|
||||||
|
config = QwenImageTransformer2DModel.load_config(
|
||||||
|
"Qwen/Qwen-Image-Edit", subfolder="transformer"
|
||||||
|
)
|
||||||
|
|
||||||
|
state_dict = safetensors.torch.load_file(path)
|
||||||
|
prefix = "model.diffusion_model."
|
||||||
|
|
||||||
|
# Convert FP8 -> target dtype, freeing FP8 tensors as we go
|
||||||
|
cleaned = {}
|
||||||
|
for k in list(state_dict.keys()):
|
||||||
|
if k.startswith(prefix):
|
||||||
|
v = state_dict.pop(k)
|
||||||
|
cleaned[k[len(prefix):]] = v.to(dtype)
|
||||||
|
del v
|
||||||
|
del state_dict
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
# Create model on meta device to avoid allocating full model in addition to cleaned dict
|
||||||
|
with init_empty_weights():
|
||||||
|
model = QwenImageTransformer2DModel.from_config(config, torch_dtype=dtype)
|
||||||
|
|
||||||
|
model.load_state_dict(cleaned, strict=True, assign=True)
|
||||||
|
del cleaned
|
||||||
|
gc.collect()
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def edit(
|
||||||
|
model_path: str,
|
||||||
|
input_path: str,
|
||||||
|
prompt: str,
|
||||||
|
steps: int = 20,
|
||||||
|
cfg: float = 4.0,
|
||||||
|
seed: int | None = None,
|
||||||
|
lora_path: str | None = None,
|
||||||
|
output_path: str = "output.png",
|
||||||
|
) -> None:
|
||||||
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
dtype = torch.bfloat16
|
||||||
|
|
||||||
|
if seed is None:
|
||||||
|
seed = random.randint(0, 2**32 - 1)
|
||||||
|
|
||||||
|
output = Path(output_path)
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
|
||||||
|
transformer = _load_transformer(model_path, dtype)
|
||||||
|
vae = AutoencoderKLQwenImage.from_pretrained(VAE_ID, subfolder="vae", torch_dtype=dtype)
|
||||||
|
text_encoder = Qwen2_5_VLForConditionalGeneration.from_pretrained(
|
||||||
|
TEXT_ENCODER_ID, torch_dtype=dtype
|
||||||
|
)
|
||||||
|
tokenizer = Qwen2Tokenizer.from_pretrained(TEXT_ENCODER_ID)
|
||||||
|
processor = Qwen2VLProcessor.from_pretrained(TEXT_ENCODER_ID)
|
||||||
|
scheduler = FlowMatchEulerDiscreteScheduler()
|
||||||
|
|
||||||
|
pipe = QwenImageEditPlusPipeline(
|
||||||
|
scheduler=scheduler,
|
||||||
|
vae=vae,
|
||||||
|
text_encoder=text_encoder,
|
||||||
|
tokenizer=tokenizer,
|
||||||
|
processor=processor,
|
||||||
|
transformer=transformer,
|
||||||
|
)
|
||||||
|
pipe.to(device)
|
||||||
|
|
||||||
|
if lora_path:
|
||||||
|
pipe.load_lora_weights(lora_path)
|
||||||
|
pipe.fuse_lora()
|
||||||
|
|
||||||
|
t_load = time.perf_counter() - t0
|
||||||
|
|
||||||
|
input_image = Image.open(input_path).convert("RGB")
|
||||||
|
generator = torch.Generator(device=device).manual_seed(seed)
|
||||||
|
|
||||||
|
t1 = time.perf_counter()
|
||||||
|
image = pipe(
|
||||||
|
image=input_image,
|
||||||
|
prompt=prompt,
|
||||||
|
true_cfg_scale=cfg,
|
||||||
|
num_inference_steps=steps,
|
||||||
|
generator=generator,
|
||||||
|
).images[0]
|
||||||
|
t_infer = time.perf_counter() - t1
|
||||||
|
|
||||||
|
image.save(output)
|
||||||
|
print(f"Saved {output}")
|
||||||
|
|
||||||
|
meta_path = output.with_suffix(".json")
|
||||||
|
meta = {
|
||||||
|
"model": str(Path(model_path).resolve()),
|
||||||
|
"vae": VAE_ID,
|
||||||
|
"text_encoder": TEXT_ENCODER_ID,
|
||||||
|
"input_image": str(Path(input_path).resolve()),
|
||||||
|
"prompt": prompt,
|
||||||
|
"steps": steps,
|
||||||
|
"cfg": cfg,
|
||||||
|
"seed": seed,
|
||||||
|
"lora": str(Path(lora_path).resolve()) if lora_path else None,
|
||||||
|
"load_time_s": round(t_load, 2),
|
||||||
|
"inference_time_s": round(t_infer, 2),
|
||||||
|
}
|
||||||
|
meta_path.write_text(json.dumps(meta, indent=2))
|
||||||
|
print(f"Saved {meta_path}")
|
||||||
Reference in New Issue
Block a user