Add support for Qwen Image 2.1
This commit is contained in:
1 parent
8fa15e3569
commit
c2622ed250
13 files changed
+4247
-3
No files matched your search
@@ -17,6 +17,7 @@ AI Toolkit is an easy to use all in one training suite for diffusion models. I t
|
|||||||
- [Alpha-VLLM/Lumina-Image-2.0](https://huggingface.co/Alpha-VLLM/Lumina-Image-2.0) (Lumina2)
|
- [Alpha-VLLM/Lumina-Image-2.0](https://huggingface.co/Alpha-VLLM/Lumina-Image-2.0) (Lumina2)
|
||||||
- [Qwen/Qwen-Image](https://huggingface.co/Qwen/Qwen-Image) (Qwen-Image)
|
- [Qwen/Qwen-Image](https://huggingface.co/Qwen/Qwen-Image) (Qwen-Image)
|
||||||
- [Qwen/Qwen-Image-2512](https://huggingface.co/Qwen/Qwen-Image-2512) (Qwen-Image-2512)
|
- [Qwen/Qwen-Image-2512](https://huggingface.co/Qwen/Qwen-Image-2512) (Qwen-Image-2512)
|
||||||
|
- [Qwen/Qwen-Image-2.1](https://huggingface.co/Qwen/Qwen-Image-2.1) (Qwen-Image-2.1)
|
||||||
- [HiDream-ai/HiDream-I1-Full](https://huggingface.co/HiDream-ai/HiDream-I1-Full) (HiDream I1)
|
- [HiDream-ai/HiDream-I1-Full](https://huggingface.co/HiDream-ai/HiDream-I1-Full) (HiDream I1)
|
||||||
- [OmniGen2/OmniGen2](https://huggingface.co/OmniGen2/OmniGen2) (OmniGen2)
|
- [OmniGen2/OmniGen2](https://huggingface.co/OmniGen2/OmniGen2) (OmniGen2)
|
||||||
- [Tongyi-MAI/Z-Image-Turbo](https://huggingface.co/Tongyi-MAI/Z-Image-Turbo) (Z-Image Turbo)
|
- [Tongyi-MAI/Z-Image-Turbo](https://huggingface.co/Tongyi-MAI/Z-Image-Turbo) (Z-Image Turbo)
|
||||||
@@ -41,6 +42,7 @@ AI Toolkit is an easy to use all in one training suite for diffusion models. I t
|
|||||||
- [Qwen/Qwen-Image-Edit](https://huggingface.co/Qwen/Qwen-Image-Edit) (Qwen-Image-Edit)
|
- [Qwen/Qwen-Image-Edit](https://huggingface.co/Qwen/Qwen-Image-Edit) (Qwen-Image-Edit)
|
||||||
- [Qwen/Qwen-Image-Edit-2509](https://huggingface.co/Qwen/Qwen-Image-Edit-2509) (Qwen-Image-Edit-2509)
|
- [Qwen/Qwen-Image-Edit-2509](https://huggingface.co/Qwen/Qwen-Image-Edit-2509) (Qwen-Image-Edit-2509)
|
||||||
- [Qwen/Qwen-Image-Edit-2511](https://huggingface.co/Qwen/Qwen-Image-Edit-2511) (Qwen-Image-Edit-2511)
|
- [Qwen/Qwen-Image-Edit-2511](https://huggingface.co/Qwen/Qwen-Image-Edit-2511) (Qwen-Image-Edit-2511)
|
||||||
|
- [Qwen/Qwen-Image-2.1](https://huggingface.co/Qwen/Qwen-Image-2.1) (Qwen-Image-2.1) - one model for both; it edits when your dataset has control images
|
||||||
- [HiDream-ai/HiDream-E1-1](https://huggingface.co/HiDream-ai/HiDream-E1-1) (HiDream E1)
|
- [HiDream-ai/HiDream-E1-1](https://huggingface.co/HiDream-ai/HiDream-E1-1) (HiDream E1)
|
||||||
- [Boogu/Boogu-Image-0.1-Edit](https://huggingface.co/Boogu/Boogu-Image-0.1-Edit) (Boogu Image Edit)
|
- [Boogu/Boogu-Image-0.1-Edit](https://huggingface.co/Boogu/Boogu-Image-0.1-Edit) (Boogu Image Edit)
|
||||||
- [krea/Krea-2-Raw](https://huggingface.co/krea/Krea-2-Raw) (Krea 2 Edit Training)
|
- [krea/Krea-2-Raw](https://huggingface.co/krea/Krea-2-Raw) (Krea 2 Edit Training)
|
||||||
@@ -57,15 +59,20 @@ AI Toolkit is an easy to use all in one training suite for diffusion models. I t
|
|||||||
- [Wan-AI/Wan2.2-TI2V-5B-Diffusers](https://huggingface.co/Wan-AI/Wan2.2-TI2V-5B-Diffusers) (Wan 2.2 TI2V 5B)
|
- [Wan-AI/Wan2.2-TI2V-5B-Diffusers](https://huggingface.co/Wan-AI/Wan2.2-TI2V-5B-Diffusers) (Wan 2.2 TI2V 5B)
|
||||||
- [Lightricks/LTX-2](https://huggingface.co/Lightricks/LTX-2) (LTX-2)
|
- [Lightricks/LTX-2](https://huggingface.co/Lightricks/LTX-2) (LTX-2)
|
||||||
- [Lightricks/LTX-2.3](https://huggingface.co/Lightricks/LTX-2.3) (LTX-2.3)
|
- [Lightricks/LTX-2.3](https://huggingface.co/Lightricks/LTX-2.3) (LTX-2.3)
|
||||||
|
- [Lightricks/LTX-2.5](https://huggingface.co/Lightricks/LTX-2.5) (LTX-2.5)
|
||||||
- [MiniMaxAI/MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3) (MiniMaxAI/MiniMax-H3)
|
- [MiniMaxAI/MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3) (MiniMaxAI/MiniMax-H3)
|
||||||
|
- [MiniMaxAI/MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3) (MiniMax-H3 Ref2V) - reference image to video
|
||||||
|
|
||||||
### Audio
|
### Audio
|
||||||
- [ACE-Step/Ace-Step1.5](https://huggingface.co/ACE-Step/Ace-Step1.5) (Ace Step 1.5)
|
- [ACE-Step/Ace-Step1.5](https://huggingface.co/ACE-Step/Ace-Step1.5) (Ace Step 1.5)
|
||||||
- [ACE-Step/acestep-v15-xl-base](https://huggingface.co/ACE-Step/acestep-v15-xl-base) (Ace Step 1.5 XL)
|
- [ACE-Step/acestep-v15-xl-base](https://huggingface.co/ACE-Step/acestep-v15-xl-base) (Ace Step 1.5 XL)
|
||||||
|
- [m-a-p/YuE2-3B](https://huggingface.co/m-a-p/YuE2-3B) (YuE2) - the official audio-to-token encoder is unreleased; training uses the community tokenizer by Kytra ([@sin_ceriously](https://x.com/sin_ceriously)), [Mothersuperior/yue2-mothersuperior-realaudio-tokenizer-v4](https://huggingface.co/Mothersuperior/yue2-mothersuperior-realaudio-tokenizer-v4).
|
||||||
|
|
||||||
|
### LLM
|
||||||
|
- [Qwen/Qwen2.5-Omni-7B](https://huggingface.co/Qwen/Qwen2.5-Omni-7B) (Qwen2.5-Omni)
|
||||||
|
|
||||||
### Experimental
|
### Experimental
|
||||||
- [lodestones/Zeta-Chroma](https://huggingface.co/lodestones/Zeta-Chroma) (Zeta Chroma)
|
- [lodestones/Zeta-Chroma](https://huggingface.co/lodestones/Zeta-Chroma) (Zeta Chroma)
|
||||||
- [m-a-p/YuE2-3B](https://huggingface.co/m-a-p/YuE2-3B) (YuE2) - audio. The official audio-to-token encoder is unreleased; training uses the community tokenizer by Kytra ([@sin_ceriously](https://x.com/sin_ceriously)), [Mothersuperior/yue2-mothersuperior-realaudio-tokenizer-v4](https://huggingface.co/Mothersuperior/yue2-mothersuperior-realaudio-tokenizer-v4).
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from .omnigen2 import OmniGen2Model
|
|||||||
from .flux_kontext import FluxKontextModel
|
from .flux_kontext import FluxKontextModel
|
||||||
from .wan22 import Wan225bModel, Wan2214bModel, Wan2214bI2VModel
|
from .wan22 import Wan225bModel, Wan2214bModel, Wan2214bI2VModel
|
||||||
from .qwen_image import QwenImageModel, QwenImageEditModel, QwenImageEditPlusModel
|
from .qwen_image import QwenImageModel, QwenImageEditModel, QwenImageEditPlusModel
|
||||||
|
from .qwen_image_2 import QwenImage2Model
|
||||||
from .flux2 import Flux2Model, Flux2Klein4BModel, Flux2Klein9BModel
|
from .flux2 import Flux2Model, Flux2Klein4BModel, Flux2Klein9BModel
|
||||||
from .z_image import ZImageModel
|
from .z_image import ZImageModel
|
||||||
from .ltx2 import LTX2Model, LTX23Model, LTX25Model
|
from .ltx2 import LTX2Model, LTX23Model, LTX25Model
|
||||||
@@ -36,6 +37,7 @@ AI_TOOLKIT_MODELS = [
|
|||||||
QwenImageModel,
|
QwenImageModel,
|
||||||
QwenImageEditModel,
|
QwenImageEditModel,
|
||||||
QwenImageEditPlusModel,
|
QwenImageEditPlusModel,
|
||||||
|
QwenImage2Model,
|
||||||
Flux2Model,
|
Flux2Model,
|
||||||
ZImageModel,
|
ZImageModel,
|
||||||
LTX2Model,
|
LTX2Model,
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .qwen_image_2 import QwenImage2Model
|
||||||
|
|
||||||
|
__all__ = ["QwenImage2Model"]
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
"""Qwen-Image 2.1 for ai-toolkit.
|
||||||
|
|
||||||
|
One checkpoint does text-to-image and editing, so there is one arch: give it
|
||||||
|
reference images and it edits, give it none and it is plain T2I.
|
||||||
|
|
||||||
|
The pieces, all pulled from the Comfy-Org repack into the toolkit's ComfyUI
|
||||||
|
models folder:
|
||||||
|
|
||||||
|
- transformer: 32-layer single-stream block-causal DiT (`src/transformer.py`),
|
||||||
|
- text encoder: Qwen3-VL-8B (`src/text_encoder.py`), read at the last decoder
|
||||||
|
layer BEFORE its final RMSNorm,
|
||||||
|
- VAE: RGBA, 16x spatial, 64 latent channels (`src/vae.py`).
|
||||||
|
|
||||||
|
Text and images share one sequence: the Qwen3-VL encoder reserves a
|
||||||
|
`<|image_pad|>` slot per reference image and the DiT drops that image's VAE
|
||||||
|
latents into the slot, four latent tokens per slot. Reference tokens and text
|
||||||
|
are modulated from t=0 (`causal_condition`), so only the target image's tokens
|
||||||
|
see the sampled timestep.
|
||||||
|
|
||||||
|
Flow-matching convention matches ai-toolkit (t=1 noise -> t=0 clean, target =
|
||||||
|
noise - clean), so `get_noise_prediction` does no time flip or negation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from toolkit.accelerator import unwrap_model
|
||||||
|
from toolkit.advanced_prompt_embeds import AdvancedPromptEmbeds
|
||||||
|
from toolkit.basic import flush
|
||||||
|
from toolkit.config_modules import GenerateImageConfig, ModelConfig
|
||||||
|
from toolkit.metadata import get_meta_for_safetensors
|
||||||
|
from toolkit.models.base_model import BaseModel
|
||||||
|
from toolkit.samplers.custom_flowmatch_sampler import (
|
||||||
|
CustomFlowMatchEulerDiscreteScheduler,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .src.pipeline import (
|
||||||
|
QwenImage21Pipeline,
|
||||||
|
QwenImage21PromptEncoder,
|
||||||
|
VAE_SCALE_FACTOR,
|
||||||
|
VISION_TOKEN_PIXELS,
|
||||||
|
pack_latents,
|
||||||
|
pad_prompt_batch,
|
||||||
|
prepare_condition_image,
|
||||||
|
run_transformer,
|
||||||
|
tensor_to_pil,
|
||||||
|
)
|
||||||
|
from .src.text_encoder import QwenImage21TextEncoder
|
||||||
|
from .src.transformer import QwenImage21Transformer2DModel
|
||||||
|
from .src.vae import AutoencoderKLQwenImage21
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from toolkit.data_transfer_object.data_loader import DataLoaderBatchDTO
|
||||||
|
|
||||||
|
|
||||||
|
# matches scheduler/scheduler_config.json in the base repo
|
||||||
|
scheduler_config = {
|
||||||
|
"base_image_seq_len": 256,
|
||||||
|
"base_shift": 0.5,
|
||||||
|
"invert_sigmas": False,
|
||||||
|
"max_image_seq_len": 8192,
|
||||||
|
"max_shift": 0.9,
|
||||||
|
"num_train_timesteps": 1000,
|
||||||
|
"shift": 1.0,
|
||||||
|
"shift_terminal": 0.02,
|
||||||
|
"stochastic_sampling": False,
|
||||||
|
"time_shift_type": "exponential",
|
||||||
|
"use_beta_sigmas": False,
|
||||||
|
"use_dynamic_shifting": True,
|
||||||
|
"use_exponential_sigmas": False,
|
||||||
|
"use_karras_sigmas": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
# the Comfy-Org repack is the weight source; the original repo supplies the
|
||||||
|
# configs and the processor, which the repack does not carry
|
||||||
|
COMFY_REPO = "Comfy-Org/Qwen-Image-2.1"
|
||||||
|
BASE_REPO = "Qwen/Qwen-Image-2.1"
|
||||||
|
|
||||||
|
# decode above this many output pixels goes through the VAE's tiled path
|
||||||
|
TILE_DECODE_ABOVE_PIXELS = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_repeats(images):
|
||||||
|
"""Drop a reference that repeats the one before it.
|
||||||
|
|
||||||
|
`GenerateImageConfig.ctrl_img_1` defaults to `ctrl_img`, so the sampler
|
||||||
|
hands the same reference over twice when only one was configured. Here that
|
||||||
|
is not merely wasteful: the prompt would reserve slots for two images while
|
||||||
|
only one set of latents arrives.
|
||||||
|
"""
|
||||||
|
kept = []
|
||||||
|
for image in images:
|
||||||
|
previous = kept[-1] if kept else None
|
||||||
|
if (
|
||||||
|
previous is not None
|
||||||
|
and previous.shape == image.shape
|
||||||
|
and torch.equal(previous, image)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
kept.append(image)
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
class QwenImage2Model(BaseModel):
|
||||||
|
arch = "qwen_image_2"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device,
|
||||||
|
model_config: ModelConfig,
|
||||||
|
dtype="bf16",
|
||||||
|
custom_pipeline=None,
|
||||||
|
noise_scheduler=None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
device, model_config, dtype, custom_pipeline, noise_scheduler, **kwargs
|
||||||
|
)
|
||||||
|
self.is_flow_matching = True
|
||||||
|
self.is_transformer = True
|
||||||
|
self.target_lora_modules = ["QwenImage21Transformer2DModel"]
|
||||||
|
self.vae_scale_factor = VAE_SCALE_FACTOR
|
||||||
|
self.prompt_encoder: Optional[QwenImage21PromptEncoder] = None
|
||||||
|
|
||||||
|
# Editing is not a separate model here, it is what happens when the
|
||||||
|
# dataset has a control path: reference images ride into the text
|
||||||
|
# embeddings as vision tokens and their latents into the sequence.
|
||||||
|
self.encode_control_in_text_embeddings = True
|
||||||
|
self.has_multiple_control_images = True
|
||||||
|
# References are bucket-resized to the target size by the dataloader.
|
||||||
|
# The DiT reads the image-slot layout from row 0 of the batch, so every
|
||||||
|
# sample must contribute the same slot count -- raw (per-image aspect)
|
||||||
|
# references would break that.
|
||||||
|
self.use_raw_control_images = False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_train_scheduler():
|
||||||
|
return CustomFlowMatchEulerDiscreteScheduler(**scheduler_config)
|
||||||
|
|
||||||
|
def get_bucket_divisibility(self):
|
||||||
|
# 16 for the VAE, 2 more because the DiT groups target latent tokens
|
||||||
|
# into 2x2 blocks, one per vision slot
|
||||||
|
return VISION_TOKEN_PIXELS
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Loading
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def load_model(self):
|
||||||
|
dtype = self.torch_dtype
|
||||||
|
self.print_and_status_update("Loading Qwen-Image 2.1 model")
|
||||||
|
model_path = self.model_config.name_or_path
|
||||||
|
base_model_path = self.model_config.extras_name_or_path
|
||||||
|
|
||||||
|
if base_model_path == model_path and not os.path.isdir(base_model_path):
|
||||||
|
# extras default to name_or_path, which is the comfy repack (or a
|
||||||
|
# single file); neither carries the configs or the processor
|
||||||
|
base_model_path = BASE_REPO
|
||||||
|
elif os.path.isdir(model_path) and os.path.isdir(
|
||||||
|
os.path.join(model_path, "text_encoder")
|
||||||
|
):
|
||||||
|
# a local full checkpoint supplies its own text encoder / vae
|
||||||
|
base_model_path = model_path
|
||||||
|
|
||||||
|
self.print_and_status_update("Loading transformer")
|
||||||
|
transformer = QwenImage21Transformer2DModel.load(
|
||||||
|
model_path,
|
||||||
|
config_path=base_model_path,
|
||||||
|
**self.component_load_kwargs("transformer"),
|
||||||
|
)
|
||||||
|
flush()
|
||||||
|
|
||||||
|
self.print_and_status_update("Loading text encoder")
|
||||||
|
processor = QwenImage21TextEncoder.load_processor(base_model_path)
|
||||||
|
text_encoder = QwenImage21TextEncoder.load_model(
|
||||||
|
base_model_path, dtype=dtype, subfolder="text_encoder"
|
||||||
|
)
|
||||||
|
# the vision tower stays: any prompt may carry reference images. bf16
|
||||||
|
# Conv3d has no fast kernel, the equivalent GEMM does
|
||||||
|
text_encoder.patch_vision_patch_embed()
|
||||||
|
text_encoder.aitk_post_load(**self.component_load_kwargs("te"))
|
||||||
|
text_encoder.requires_grad_(False)
|
||||||
|
text_encoder.eval()
|
||||||
|
flush()
|
||||||
|
|
||||||
|
self.print_and_status_update("Loading VAE")
|
||||||
|
vae = AutoencoderKLQwenImage21.load(
|
||||||
|
base_model_path, **self.component_load_kwargs("vae")
|
||||||
|
)
|
||||||
|
vae.requires_grad_(False)
|
||||||
|
vae.eval()
|
||||||
|
|
||||||
|
self.noise_scheduler = QwenImage2Model.get_train_scheduler()
|
||||||
|
|
||||||
|
self.vae = vae
|
||||||
|
self.text_encoder = [text_encoder]
|
||||||
|
self.tokenizer = [processor.tokenizer]
|
||||||
|
self.processor = processor
|
||||||
|
self.model = transformer
|
||||||
|
self.prompt_encoder = QwenImage21PromptEncoder(text_encoder, processor)
|
||||||
|
self.pipeline = QwenImage21Pipeline(self)
|
||||||
|
self.print_and_status_update("Model Loaded")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# VAE. The latents are RGBA; toolkit images are RGB, so encode pads an
|
||||||
|
# opaque alpha channel and decode drops it again (unless model_kwargs.rgba).
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def output_rgba(self) -> bool:
|
||||||
|
return bool(self.model_config.model_kwargs.get("rgba", False))
|
||||||
|
|
||||||
|
def _latent_stats(self, device, dtype):
|
||||||
|
shape = (1, self.vae.config.z_dim, 1, 1, 1)
|
||||||
|
mean = torch.tensor(self.vae.config.latents_mean).view(shape).to(device, dtype)
|
||||||
|
std = torch.tensor(self.vae.config.latents_std).view(shape).to(device, dtype)
|
||||||
|
return mean, std
|
||||||
|
|
||||||
|
def encode_images(self, image_list, device=None, dtype=None):
|
||||||
|
device = device or self.vae_device_torch
|
||||||
|
dtype = dtype or self.vae_torch_dtype
|
||||||
|
|
||||||
|
if self.vae.device == torch.device("cpu"):
|
||||||
|
self.vae.to(device)
|
||||||
|
self.vae.eval()
|
||||||
|
|
||||||
|
images = torch.stack(
|
||||||
|
[image.to(device, dtype=dtype) for image in image_list]
|
||||||
|
).to(device, dtype=dtype)
|
||||||
|
if images.shape[1] == 3:
|
||||||
|
# opaque alpha, in the [-1, 1] the VAE reads
|
||||||
|
images = torch.cat([images, torch.ones_like(images[:, :1])], dim=1)
|
||||||
|
images = images.unsqueeze(2) # single-frame dim
|
||||||
|
|
||||||
|
latents = self.vae.encode(images).latent_dist.sample()
|
||||||
|
mean, std = self._latent_stats(latents.device, latents.dtype)
|
||||||
|
latents = (latents - mean) / std
|
||||||
|
return latents.squeeze(2).to(device, dtype=dtype)
|
||||||
|
|
||||||
|
def decode_latents(self, latents: torch.Tensor, device=None, dtype=None):
|
||||||
|
images = self._decode_rgba(latents, device=device, dtype=dtype)
|
||||||
|
if not self.output_rgba:
|
||||||
|
images = images[:, :3]
|
||||||
|
return images
|
||||||
|
|
||||||
|
def _decode_rgba(self, latents: torch.Tensor, device=None, dtype=None):
|
||||||
|
device = device or self.vae_device_torch
|
||||||
|
dtype = dtype or self.vae_torch_dtype
|
||||||
|
|
||||||
|
if self.vae.device == torch.device("cpu"):
|
||||||
|
self.vae.to(device)
|
||||||
|
|
||||||
|
latents = latents.to(device, dtype=dtype).unsqueeze(2)
|
||||||
|
mean, std = self._latent_stats(latents.device, latents.dtype)
|
||||||
|
latents = latents * std + mean
|
||||||
|
|
||||||
|
# A one-shot decode of this VAE's 16x upsample stack is heavy: 2048x2048
|
||||||
|
# -- the resolution Qwen recommends -- needs more than a 32 GB card has.
|
||||||
|
# Tile above 1 MP, and whenever low_vram is set.
|
||||||
|
pixels = latents.shape[-2] * latents.shape[-1] * self.vae_scale_factor**2
|
||||||
|
tiled = self.model_config.low_vram or pixels > TILE_DECODE_ABOVE_PIXELS
|
||||||
|
if tiled:
|
||||||
|
self.vae.enable_tiling(
|
||||||
|
tile_sample_min_height=1024,
|
||||||
|
tile_sample_min_width=1024,
|
||||||
|
tile_sample_stride_height=768,
|
||||||
|
tile_sample_stride_width=768,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
images = self.vae.decode(latents).sample
|
||||||
|
finally:
|
||||||
|
if tiled:
|
||||||
|
self.vae.disable_tiling()
|
||||||
|
return images.squeeze(2).to(device, dtype=dtype)
|
||||||
|
|
||||||
|
def decode_to_images(self, latents: torch.Tensor) -> List[Image.Image]:
|
||||||
|
"""Decode to PIL, keeping the alpha channel when model_kwargs.rgba is set."""
|
||||||
|
return [
|
||||||
|
self.image_tensor_to_pil(image) for image in self.decode_latents(latents)
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def image_tensor_to_pil(image: torch.Tensor) -> Image.Image:
|
||||||
|
"""`(C, H, W)` in [-1, 1] -> PIL (RGB, or RGBA when C is 4)."""
|
||||||
|
array = (image.float().clamp(-1, 1) / 2 + 0.5).permute(1, 2, 0)
|
||||||
|
array = (array.cpu().numpy() * 255).round().astype(np.uint8)
|
||||||
|
return Image.fromarray(array, mode="RGBA" if array.shape[2] == 4 else "RGB")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Reference images
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def control_image_max_pixels(self) -> int:
|
||||||
|
"""Pixel budget a reference image is shrunk to fit. A reference that is
|
||||||
|
already smaller keeps its size (only snapped to the 32 px grid), so a
|
||||||
|
bucket-resized training reference stays at the target resolution."""
|
||||||
|
return int(
|
||||||
|
self.model_config.model_kwargs.get("control_image_max_pixels", 1024 * 1024)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _normalize_control_images(self, control_images, batch_size: int) -> List[List]:
|
||||||
|
"""Any of the shapes the toolkit hands over -> one list per batch item.
|
||||||
|
|
||||||
|
Control images arrive as a `(B, C, H, W)` batch tensor, a
|
||||||
|
`(B, N, C, H, W)` multi-reference tensor, a per-sample list of lists, or
|
||||||
|
a flat list for a single prompt (sampling / blank-embed caching).
|
||||||
|
"""
|
||||||
|
if control_images is None:
|
||||||
|
return [[] for _ in range(batch_size)]
|
||||||
|
if isinstance(control_images, torch.Tensor):
|
||||||
|
if control_images.dim() == 5:
|
||||||
|
control_images = [list(sample) for sample in control_images]
|
||||||
|
else:
|
||||||
|
control_images = [[sample] for sample in control_images]
|
||||||
|
elif len(control_images) > 0 and not isinstance(control_images[0], list):
|
||||||
|
control_images = [list(control_images)]
|
||||||
|
control_images = [_drop_repeats(sample) for sample in control_images]
|
||||||
|
if len(control_images) == 1 and batch_size > 1:
|
||||||
|
control_images = control_images * batch_size
|
||||||
|
if len(control_images) != batch_size:
|
||||||
|
raise ValueError(
|
||||||
|
f"got {len(control_images)} control image sets for {batch_size} prompts"
|
||||||
|
)
|
||||||
|
return control_images
|
||||||
|
|
||||||
|
def _prepare_control_images(
|
||||||
|
self, control_images: List[List[torch.Tensor]]
|
||||||
|
) -> List[List[torch.Tensor]]:
|
||||||
|
"""Put every reference on the 32 px grid, as `(1, C, H, W)` in [0, 1]."""
|
||||||
|
budget = self.control_image_max_pixels
|
||||||
|
prepared = []
|
||||||
|
for sample in control_images:
|
||||||
|
images = []
|
||||||
|
for image in sample:
|
||||||
|
if image.dim() == 3:
|
||||||
|
image = image.unsqueeze(0)
|
||||||
|
images.append(
|
||||||
|
prepare_condition_image(image.to(self.device_torch), budget)
|
||||||
|
)
|
||||||
|
prepared.append(images)
|
||||||
|
return prepared
|
||||||
|
|
||||||
|
def encode_condition_images(self, control_images):
|
||||||
|
"""Reference images -> `(B, N, C)` packed latents plus their latent grids.
|
||||||
|
|
||||||
|
`control_images` is a per-sample list of `(1, C, H, W)` tensors in
|
||||||
|
[0, 1], already at their final size.
|
||||||
|
"""
|
||||||
|
if not control_images or not any(len(sample) for sample in control_images):
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
sample_latents, shapes = [], []
|
||||||
|
for index, sample in enumerate(control_images):
|
||||||
|
packed = []
|
||||||
|
for image in sample:
|
||||||
|
latent = self.encode_images(
|
||||||
|
[image[0].to(self.device_torch) * 2 - 1],
|
||||||
|
device=self.device_torch,
|
||||||
|
dtype=self.torch_dtype,
|
||||||
|
)
|
||||||
|
if index == 0:
|
||||||
|
shapes.append((latent.shape[2], latent.shape[3]))
|
||||||
|
packed.append(pack_latents(latent))
|
||||||
|
sample_latents.append(torch.cat(packed, dim=1))
|
||||||
|
|
||||||
|
lengths = {latents.shape[1] for latents in sample_latents}
|
||||||
|
if len(lengths) > 1:
|
||||||
|
raise ValueError(
|
||||||
|
"every sample in a batch must contribute the same reference image "
|
||||||
|
f"token count, got {sorted(lengths)}. The dataloader bucket-resizes "
|
||||||
|
"reference images, so this means the samples disagree on how many."
|
||||||
|
)
|
||||||
|
return torch.cat(sample_latents, dim=0), shapes
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Prompts
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def get_prompt_embeds(self, prompt, control_images=None) -> AdvancedPromptEmbeds:
|
||||||
|
if isinstance(prompt, str):
|
||||||
|
prompt = [prompt]
|
||||||
|
if self.text_encoder[0].device != self.device_torch:
|
||||||
|
self.text_encoder[0].to(self.device_torch)
|
||||||
|
|
||||||
|
images = None
|
||||||
|
if control_images is not None:
|
||||||
|
samples = self._prepare_control_images(
|
||||||
|
self._normalize_control_images(control_images, len(prompt))
|
||||||
|
)
|
||||||
|
images = [[tensor_to_pil(image) for image in sample] for sample in samples]
|
||||||
|
|
||||||
|
embeds, masks, slot_masks = self.prompt_encoder.encode(
|
||||||
|
prompt, images=images, device=self.device_torch
|
||||||
|
)
|
||||||
|
pe = AdvancedPromptEmbeds(
|
||||||
|
text_embeds=[embed.to(self.torch_dtype) for embed in embeds],
|
||||||
|
attention_mask=masks,
|
||||||
|
image_slot_mask=[slots.to(torch.bool) for slots in slot_masks],
|
||||||
|
)
|
||||||
|
# the masks are bookkeeping, not activations: a .to(dtype) must not
|
||||||
|
# turn them into bf16
|
||||||
|
pe.frozen_dtype_keys = ["attention_mask", "image_slot_mask"]
|
||||||
|
return pe
|
||||||
|
|
||||||
|
def pad_prompt_embeds(self, prompt_embeds: AdvancedPromptEmbeds):
|
||||||
|
"""`AdvancedPromptEmbeds` -> the padded `(embeds, mask, slot_mask)` batch."""
|
||||||
|
return pad_prompt_batch(
|
||||||
|
prompt_embeds.text_embeds,
|
||||||
|
prompt_embeds.attention_mask,
|
||||||
|
prompt_embeds.image_slot_mask,
|
||||||
|
self.device_torch,
|
||||||
|
self.torch_dtype,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Training
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def condition_noisy_latents(
|
||||||
|
self, latents: torch.Tensor, batch: "DataLoaderBatchDTO"
|
||||||
|
):
|
||||||
|
# reference latents join the sequence in get_noise_prediction, clean
|
||||||
|
return latents.detach()
|
||||||
|
|
||||||
|
def get_noise_prediction(
|
||||||
|
self,
|
||||||
|
latent_model_input: torch.Tensor, # (B, 64, h, w)
|
||||||
|
timestep: torch.Tensor, # 0..1000 scale
|
||||||
|
text_embeddings: AdvancedPromptEmbeds,
|
||||||
|
batch: "DataLoaderBatchDTO" = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
if self.model.device == torch.device("cpu"):
|
||||||
|
self.model.to(self.device_torch)
|
||||||
|
batch_size = latent_model_input.shape[0]
|
||||||
|
|
||||||
|
prompt_embeds, prompt_mask, slot_mask = self.pad_prompt_embeds(text_embeddings)
|
||||||
|
|
||||||
|
# The prompt is what decides: it reserved the slots the references go
|
||||||
|
# into, so a prompt encoded without them (a plain T2I dataset, a fully
|
||||||
|
# dropped caption) takes no references here either.
|
||||||
|
condition_latents, condition_shapes = None, []
|
||||||
|
if batch is not None and bool(slot_mask.any()):
|
||||||
|
with torch.no_grad():
|
||||||
|
control = batch.control_tensor_list
|
||||||
|
if control is None:
|
||||||
|
control = batch.control_tensor
|
||||||
|
samples = self._prepare_control_images(
|
||||||
|
self._normalize_control_images(control, batch_size)
|
||||||
|
)
|
||||||
|
condition_latents, condition_shapes = self.encode_condition_images(
|
||||||
|
samples
|
||||||
|
)
|
||||||
|
|
||||||
|
# toolkit timestep (0..1000, 1000 = pure noise) -> the model's t in [0, 1];
|
||||||
|
# same direction, so a plain divide
|
||||||
|
t = timestep.to(self.device_torch, dtype=self.torch_dtype) / 1000
|
||||||
|
if t.dim() == 0:
|
||||||
|
t = t.unsqueeze(0)
|
||||||
|
if t.shape[0] != batch_size:
|
||||||
|
t = t.expand(batch_size)
|
||||||
|
|
||||||
|
return run_transformer(
|
||||||
|
self.transformer,
|
||||||
|
latent_model_input.to(self.device_torch, self.torch_dtype),
|
||||||
|
t,
|
||||||
|
prompt_embeds,
|
||||||
|
prompt_mask,
|
||||||
|
slot_mask,
|
||||||
|
condition_latents=condition_latents,
|
||||||
|
condition_shapes=condition_shapes,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_loss_target(self, *args, **kwargs):
|
||||||
|
# flow-matching velocity target: noise - clean
|
||||||
|
noise = kwargs.get("noise")
|
||||||
|
batch = kwargs.get("batch")
|
||||||
|
return (noise - batch.latents).detach()
|
||||||
|
|
||||||
|
def get_model_has_grad(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_te_has_grad(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Sampling
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def get_generation_pipeline(self):
|
||||||
|
return QwenImage21Pipeline(self)
|
||||||
|
|
||||||
|
def generate_single_image(
|
||||||
|
self,
|
||||||
|
pipeline: QwenImage21Pipeline,
|
||||||
|
gen_config: GenerateImageConfig,
|
||||||
|
conditional_embeds: AdvancedPromptEmbeds,
|
||||||
|
unconditional_embeds: AdvancedPromptEmbeds,
|
||||||
|
generator: torch.Generator,
|
||||||
|
extra: dict,
|
||||||
|
):
|
||||||
|
if self.model.device == torch.device("cpu"):
|
||||||
|
self.model.to(self.device_torch)
|
||||||
|
|
||||||
|
divisor = self.get_bucket_divisibility()
|
||||||
|
gen_config.width = int(gen_config.width // divisor * divisor)
|
||||||
|
gen_config.height = int(gen_config.height // divisor * divisor)
|
||||||
|
|
||||||
|
# the same list the sampler built for the prompt embeddings, so the two
|
||||||
|
# agree on how many references there are
|
||||||
|
paths = [
|
||||||
|
path
|
||||||
|
for path in (
|
||||||
|
gen_config.ctrl_img,
|
||||||
|
gen_config.ctrl_img_1,
|
||||||
|
gen_config.ctrl_img_2,
|
||||||
|
gen_config.ctrl_img_3,
|
||||||
|
)
|
||||||
|
if path is not None
|
||||||
|
]
|
||||||
|
condition_images = None
|
||||||
|
if paths:
|
||||||
|
tensors = [
|
||||||
|
torch.from_numpy(
|
||||||
|
np.array(Image.open(path).convert("RGBA"), dtype=np.float32) / 255.0
|
||||||
|
)
|
||||||
|
.permute(2, 0, 1)
|
||||||
|
.unsqueeze(0)
|
||||||
|
for path in paths
|
||||||
|
]
|
||||||
|
condition_images = self._prepare_control_images(
|
||||||
|
self._normalize_control_images([tensors], 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
return pipeline(
|
||||||
|
conditional_embeds=conditional_embeds,
|
||||||
|
unconditional_embeds=unconditional_embeds,
|
||||||
|
height=gen_config.height,
|
||||||
|
width=gen_config.width,
|
||||||
|
num_inference_steps=gen_config.num_inference_steps,
|
||||||
|
guidance_scale=gen_config.guidance_scale,
|
||||||
|
latents=gen_config.latents,
|
||||||
|
generator=generator,
|
||||||
|
condition_images=condition_images,
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Saving / bookkeeping
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def save_model(self, output_path, meta, save_dtype):
|
||||||
|
# comfy-format single-file save; prequantized layers keep their storage
|
||||||
|
transformer: QwenImage21Transformer2DModel = unwrap_model(self.model)
|
||||||
|
if not output_path.endswith(".safetensors"):
|
||||||
|
output_path += ".safetensors"
|
||||||
|
transformer.save_model(
|
||||||
|
output_path,
|
||||||
|
dtype=save_dtype,
|
||||||
|
metadata=get_meta_for_safetensors(meta, name=self.arch),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_base_model_version(self):
|
||||||
|
return self.arch
|
||||||
|
|
||||||
|
def get_transformer_block_names(self) -> Optional[List[str]]:
|
||||||
|
return ["transformer_blocks"]
|
||||||
|
|
||||||
|
def get_quantization_exclude_modules(self):
|
||||||
|
return QwenImage21Transformer2DModel.get_quantization_exclude_modules()
|
||||||
|
|
||||||
|
lora_keys_use_comfy_prefix = True
|
||||||
Whitespace-only changes.
@@ -0,0 +1,412 @@
|
|||||||
|
"""Prompt encoding, sequence packing and a preview sampler for Qwen-Image 2.1.
|
||||||
|
|
||||||
|
Qwen-Image 2.1 puts text and images in ONE sequence. The Qwen3-VL encoder is
|
||||||
|
handed the prompt with a `<|image_pad|>` slot per condition image; the
|
||||||
|
transformer then throws the vision embeddings away and drops the VAE latents
|
||||||
|
into those slots, four latent tokens per slot (a vision token covers 32 px, a
|
||||||
|
latent token 16 px). Everything here exists to keep those two views lined up:
|
||||||
|
`encode_prompt` returns the per-slot mask alongside the embeddings, and
|
||||||
|
`run_transformer` appends one slot per 2x2 group of target latents before the
|
||||||
|
call.
|
||||||
|
|
||||||
|
Condition images are therefore resized ONCE, to a multiple of 32, and that same
|
||||||
|
resize feeds both the Qwen3-VL processor and the VAE -- a different size on
|
||||||
|
either side changes the slot/latent-token counts and the transformer rejects
|
||||||
|
the sequence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import List, Optional, Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
import torch.nn.functional as F
|
||||||
|
from PIL import Image
|
||||||
|
from diffusers.utils.torch_utils import randn_tensor
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = "Comprehend and analyze the provided prompt."
|
||||||
|
VISION_BLOCK = "<|vision_start|><|image_pad|><|vision_end|>"
|
||||||
|
PROMPT_TEMPLATE = (
|
||||||
|
f"<|im_start|>system\n{SYSTEM_PROMPT}<|im_end|>\n"
|
||||||
|
"<|im_start|>user\n{}<|im_end|>\n"
|
||||||
|
"<|im_start|>assistant\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# a Qwen3-VL vision token covers 32 px (patch 16 x merge 2), a latent token 16 px
|
||||||
|
VISION_TOKEN_PIXELS = 32
|
||||||
|
VAE_SCALE_FACTOR = 16
|
||||||
|
LATENT_TOKENS_PER_SLOT = (VISION_TOKEN_PIXELS // VAE_SCALE_FACTOR) ** 2
|
||||||
|
|
||||||
|
|
||||||
|
def _snap(size: float) -> int:
|
||||||
|
"""Round to the 32 px grid the vision tower and the VAE share."""
|
||||||
|
return max(
|
||||||
|
VISION_TOKEN_PIXELS, round(size / VISION_TOKEN_PIXELS) * VISION_TOKEN_PIXELS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_dimensions(target_area: int, ratio: float) -> tuple[int, int]:
|
||||||
|
"""Width/height with the given aspect ratio covering `target_area`, both on
|
||||||
|
the 32 px grid."""
|
||||||
|
width = math.sqrt(target_area * ratio)
|
||||||
|
return _snap(width), _snap(width / ratio)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_condition_image(image: torch.Tensor, max_pixels: int) -> torch.Tensor:
|
||||||
|
"""Put one `(1, C, H, W)` condition image on the 32 px grid, shrinking it
|
||||||
|
first if it is over the pixel budget.
|
||||||
|
|
||||||
|
Deterministic in the image alone, on purpose: the Qwen3-VL pass and the VAE
|
||||||
|
pass happen in different calls (`get_prompt_embeds` and
|
||||||
|
`get_noise_prediction`) and must agree on the size to the pixel, or the slot
|
||||||
|
count and the latent-token count disagree and the sequence is rejected.
|
||||||
|
"""
|
||||||
|
height, width = image.shape[2], image.shape[3]
|
||||||
|
if height * width > max_pixels:
|
||||||
|
new_width, new_height = calculate_dimensions(max_pixels, width / height)
|
||||||
|
else:
|
||||||
|
new_width, new_height = _snap(width), _snap(height)
|
||||||
|
if (new_height, new_width) == (height, width):
|
||||||
|
return image
|
||||||
|
return F.interpolate(
|
||||||
|
image.float(), size=(new_height, new_width), mode="bicubic", antialias=True
|
||||||
|
).clamp(0, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def tensor_to_pil(image: torch.Tensor) -> Image.Image:
|
||||||
|
"""`(1, C, H, W)` or `(C, H, W)` in [0, 1] -> RGB PIL for the vision tower.
|
||||||
|
|
||||||
|
An alpha channel is composited over white, which is what the checkpoint was
|
||||||
|
trained with; the VAE still reads all four channels.
|
||||||
|
"""
|
||||||
|
if image.dim() == 4:
|
||||||
|
image = image[0]
|
||||||
|
image = image.detach().float().clamp(0, 1).cpu()
|
||||||
|
if image.shape[0] == 4:
|
||||||
|
alpha = image[3:4]
|
||||||
|
image = image[:3] * alpha + (1.0 - alpha)
|
||||||
|
array = (image.permute(1, 2, 0).numpy() * 255).round().astype(np.uint8)
|
||||||
|
return Image.fromarray(array, mode="RGB")
|
||||||
|
|
||||||
|
|
||||||
|
class QwenImage21PromptEncoder:
|
||||||
|
"""Turns prompts (+ optional condition images) into the transformer's text
|
||||||
|
stream, its validity mask, and the image-slot mask."""
|
||||||
|
|
||||||
|
def __init__(self, text_encoder, processor):
|
||||||
|
self.text_encoder = text_encoder
|
||||||
|
self.processor = processor
|
||||||
|
# How many leading tokens the system turn takes, so it can be dropped
|
||||||
|
# from the hidden states. Derived from the processor, not hardcoded.
|
||||||
|
system_message = [
|
||||||
|
{"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT}]}
|
||||||
|
]
|
||||||
|
system_tokens = processor.apply_chat_template(
|
||||||
|
system_message, tokenize=True, return_dict=False
|
||||||
|
)
|
||||||
|
self.drop_index = len(system_tokens[0])
|
||||||
|
self.image_token_id = processor.tokenizer.encode("<|image_pad|>")[0]
|
||||||
|
|
||||||
|
def _template(self, num_images: int) -> str:
|
||||||
|
if num_images == 0:
|
||||||
|
return PROMPT_TEMPLATE
|
||||||
|
refs = " ".join(f"<image{i + 1}>{VISION_BLOCK}" for i in range(num_images))
|
||||||
|
return PROMPT_TEMPLATE.replace("{}", refs + "{}", 1)
|
||||||
|
|
||||||
|
@torch.no_grad()
|
||||||
|
def encode(
|
||||||
|
self,
|
||||||
|
prompts: Sequence[str],
|
||||||
|
images: Optional[Sequence[Sequence[Image.Image]]] = None,
|
||||||
|
device: Optional[torch.device] = None,
|
||||||
|
) -> tuple[List[torch.Tensor], List[torch.Tensor], List[torch.Tensor]]:
|
||||||
|
"""Encode a batch of prompts.
|
||||||
|
|
||||||
|
`images[i]` are the condition images for `prompts[i]`, already resized.
|
||||||
|
Returns three per-sample lists, each entry at its own natural length:
|
||||||
|
the `(L, dim)` embeddings, an `(L,)` validity mask, and an `(L,)` bool
|
||||||
|
mask marking the image slots.
|
||||||
|
"""
|
||||||
|
device = device or self.text_encoder.device
|
||||||
|
# Qwen has no bos token, so an empty prompt leaves the encoder nothing to read
|
||||||
|
prompts = [p if p else " " for p in prompts]
|
||||||
|
images = list(images) if images is not None else [[] for _ in prompts]
|
||||||
|
|
||||||
|
texts = [
|
||||||
|
self._template(len(sample_images)).format(prompt)
|
||||||
|
for prompt, sample_images in zip(prompts, images)
|
||||||
|
]
|
||||||
|
flat_images = [image for sample_images in images for image in sample_images]
|
||||||
|
|
||||||
|
processor_kwargs = {
|
||||||
|
# left padding, as the checkpoint was trained with: the padding is
|
||||||
|
# dropped either way, but the side decides the positions the
|
||||||
|
# encoder sees across prompts of different lengths
|
||||||
|
"text": texts,
|
||||||
|
"padding": True,
|
||||||
|
"padding_side": "left",
|
||||||
|
"return_tensors": "pt",
|
||||||
|
}
|
||||||
|
if flat_images:
|
||||||
|
processor_kwargs["images"] = flat_images
|
||||||
|
model_inputs = self.processor(**processor_kwargs).to(device)
|
||||||
|
|
||||||
|
forward_kwargs = {
|
||||||
|
"input_ids": model_inputs.input_ids,
|
||||||
|
"attention_mask": model_inputs.attention_mask,
|
||||||
|
"output_hidden_states": True,
|
||||||
|
}
|
||||||
|
if flat_images and hasattr(model_inputs, "pixel_values"):
|
||||||
|
forward_kwargs["pixel_values"] = model_inputs.pixel_values
|
||||||
|
forward_kwargs["image_grid_thw"] = model_inputs.image_grid_thw
|
||||||
|
if hasattr(model_inputs, "mm_token_type_ids"):
|
||||||
|
forward_kwargs["mm_token_type_ids"] = model_inputs.mm_token_type_ids
|
||||||
|
|
||||||
|
# The transformer was trained on the last decoder layer's output BEFORE
|
||||||
|
# the encoder's final RMSNorm. transformers 4.x returned that as
|
||||||
|
# hidden_states[-1]; 5.x ties that entry to last_hidden_state, so it
|
||||||
|
# comes back normalized instead. A forward hook returning the norm's
|
||||||
|
# input neutralizes it for this call on either version.
|
||||||
|
language_model = getattr(
|
||||||
|
self.text_encoder.model, "language_model", self.text_encoder.model
|
||||||
|
)
|
||||||
|
handle = language_model.norm.register_forward_hook(
|
||||||
|
lambda module, args, output: args[0]
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
outputs = self.text_encoder(**forward_kwargs)
|
||||||
|
finally:
|
||||||
|
handle.remove()
|
||||||
|
hidden_states = outputs.hidden_states[-1]
|
||||||
|
|
||||||
|
embeds, masks, slot_masks = [], [], []
|
||||||
|
for sample_ids, sample_mask, sample_hidden in zip(
|
||||||
|
model_inputs.input_ids, model_inputs.attention_mask, hidden_states
|
||||||
|
):
|
||||||
|
valid = sample_mask.bool()
|
||||||
|
sample_hidden = sample_hidden[valid][self.drop_index :]
|
||||||
|
slots = (sample_ids[valid] == self.image_token_id)[self.drop_index :]
|
||||||
|
embeds.append(sample_hidden)
|
||||||
|
masks.append(
|
||||||
|
torch.ones(sample_hidden.shape[0], dtype=torch.long, device=device)
|
||||||
|
)
|
||||||
|
slot_masks.append(slots)
|
||||||
|
return embeds, masks, slot_masks
|
||||||
|
|
||||||
|
|
||||||
|
def pad_prompt_batch(
|
||||||
|
embeds: Sequence[torch.Tensor],
|
||||||
|
masks: Sequence[torch.Tensor],
|
||||||
|
slot_masks: Sequence[torch.Tensor],
|
||||||
|
device: torch.device,
|
||||||
|
dtype: torch.dtype,
|
||||||
|
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||||
|
"""Right-pad per-sample prompt tensors into one batch.
|
||||||
|
|
||||||
|
The transformer reads the image-slot layout from row 0 and applies it to
|
||||||
|
every row, which is sound because the template puts the slots before the
|
||||||
|
prompt text: the prefix is identical across samples and only the padded
|
||||||
|
tail differs.
|
||||||
|
"""
|
||||||
|
max_len = max(e.shape[0] for e in embeds)
|
||||||
|
batch_size, dim = len(embeds), embeds[0].shape[-1]
|
||||||
|
|
||||||
|
out_embeds = torch.zeros(batch_size, max_len, dim, device=device, dtype=dtype)
|
||||||
|
out_mask = torch.zeros(batch_size, max_len, dtype=torch.bool, device=device)
|
||||||
|
out_slots = torch.zeros(batch_size, max_len, dtype=torch.bool, device=device)
|
||||||
|
for i, (embed, mask, slots) in enumerate(zip(embeds, masks, slot_masks)):
|
||||||
|
length = embed.shape[0]
|
||||||
|
out_embeds[i, :length] = embed.to(device, dtype)
|
||||||
|
out_mask[i, :length] = mask.to(device).bool()
|
||||||
|
out_slots[i, :length] = slots.to(device).bool()
|
||||||
|
return out_embeds, out_mask, out_slots
|
||||||
|
|
||||||
|
|
||||||
|
def pack_latents(latents: torch.Tensor) -> torch.Tensor:
|
||||||
|
"""`(B, C, H, W)` -> `(B, H * W, C)`. 2.1 consumes latents unpatched."""
|
||||||
|
batch_size, channels, height, width = latents.shape
|
||||||
|
return latents.view(batch_size, channels, height * width).transpose(1, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def unpack_latents(latents: torch.Tensor, height: int, width: int) -> torch.Tensor:
|
||||||
|
"""`(B, H * W, C)` -> `(B, C, H, W)`."""
|
||||||
|
batch_size, _, channels = latents.shape
|
||||||
|
return latents.transpose(1, 2).reshape(batch_size, channels, height, width)
|
||||||
|
|
||||||
|
|
||||||
|
def run_transformer(
|
||||||
|
transformer,
|
||||||
|
latents: torch.Tensor, # (B, C, h, w) noisy target latents
|
||||||
|
timestep: torch.Tensor, # (B,) in [0, 1]
|
||||||
|
prompt_embeds: torch.Tensor, # (B, L, dim)
|
||||||
|
prompt_mask: torch.Tensor, # (B, L) bool
|
||||||
|
image_slot_mask: torch.Tensor, # (B, L) bool
|
||||||
|
condition_latents: Optional[torch.Tensor] = None, # (B, N, C) packed, in slot order
|
||||||
|
condition_shapes: Sequence[tuple[int, int]] = (), # per condition image (h, w)
|
||||||
|
**kwargs,
|
||||||
|
) -> torch.Tensor:
|
||||||
|
"""Run the DiT on the joint sequence and return the target velocity as
|
||||||
|
`(B, C, h, w)`."""
|
||||||
|
batch_size, channels, height, width = latents.shape
|
||||||
|
target_tokens = height * width
|
||||||
|
|
||||||
|
hidden_states = pack_latents(latents)
|
||||||
|
if condition_latents is not None:
|
||||||
|
hidden_states = torch.cat([condition_latents, hidden_states], dim=1)
|
||||||
|
|
||||||
|
# The prompt reserved one slot per 2x2 group of each reference's latent
|
||||||
|
# tokens. Embeddings encoded against different references than the ones
|
||||||
|
# passed here (a shared blank/unconditional embedding, say) would silently
|
||||||
|
# mis-slot the sequence, so check it before the DiT does.
|
||||||
|
expected_slots = sum(h * w for h, w in condition_shapes) // LATENT_TOKENS_PER_SLOT
|
||||||
|
if int(image_slot_mask[0].sum()) != expected_slots:
|
||||||
|
raise ValueError(
|
||||||
|
f"the prompt embeddings hold {int(image_slot_mask[0].sum())} reference "
|
||||||
|
f"image slots but {len(condition_shapes)} reference image(s) were passed, "
|
||||||
|
f"worth {expected_slots} slots. They must be encoded together -- an "
|
||||||
|
"embedding cached against other references (caption dropout, a blank "
|
||||||
|
"unconditional embedding) cannot be reused here."
|
||||||
|
)
|
||||||
|
|
||||||
|
img_shapes = [
|
||||||
|
[*[(1, h, w) for h, w in condition_shapes], (1, height, width)]
|
||||||
|
] * batch_size
|
||||||
|
|
||||||
|
# one image slot per 2x2 group of target latent tokens
|
||||||
|
img_mask = torch.cat(
|
||||||
|
[
|
||||||
|
image_slot_mask,
|
||||||
|
image_slot_mask.new_ones(
|
||||||
|
batch_size, target_tokens // LATENT_TOKENS_PER_SLOT
|
||||||
|
),
|
||||||
|
],
|
||||||
|
dim=1,
|
||||||
|
)
|
||||||
|
# a mask with nothing masked costs the attention backends that reject one
|
||||||
|
encoder_mask = None if bool(prompt_mask.all()) else prompt_mask
|
||||||
|
|
||||||
|
output = transformer(
|
||||||
|
hidden_states=hidden_states,
|
||||||
|
encoder_hidden_states=prompt_embeds,
|
||||||
|
encoder_hidden_states_mask=encoder_mask,
|
||||||
|
timestep=timestep,
|
||||||
|
img_shapes=img_shapes,
|
||||||
|
img_mask=img_mask,
|
||||||
|
return_dict=False,
|
||||||
|
**kwargs,
|
||||||
|
)[0]
|
||||||
|
return unpack_latents(output[:, -target_tokens:], height, width)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_shift(
|
||||||
|
image_seq_len: int,
|
||||||
|
base_seq_len: int = 256,
|
||||||
|
max_seq_len: int = 8192,
|
||||||
|
base_shift: float = 0.5,
|
||||||
|
max_shift: float = 0.9,
|
||||||
|
) -> float:
|
||||||
|
slope = (max_shift - base_shift) / (max_seq_len - base_seq_len)
|
||||||
|
return image_seq_len * slope + base_shift - slope * base_seq_len
|
||||||
|
|
||||||
|
|
||||||
|
class QwenImage21Pipeline:
|
||||||
|
"""Minimal flow-matching sampler for ai-toolkit's preview generation."""
|
||||||
|
|
||||||
|
def __init__(self, model):
|
||||||
|
# `model` is the QwenImage2Model, so its encode/decode and config are reused
|
||||||
|
self.model = model
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device(self):
|
||||||
|
return self.model.device_torch
|
||||||
|
|
||||||
|
def to(self, *args, **kwargs):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def set_progress_bar_config(self, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@torch.no_grad()
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
conditional_embeds,
|
||||||
|
unconditional_embeds=None,
|
||||||
|
height: int = 1024,
|
||||||
|
width: int = 1024,
|
||||||
|
num_inference_steps: int = 40,
|
||||||
|
guidance_scale: float = 1.0,
|
||||||
|
latents: Optional[torch.Tensor] = None,
|
||||||
|
generator: Optional[torch.Generator] = None,
|
||||||
|
condition_images: Optional[List[torch.Tensor]] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> List[Image.Image]:
|
||||||
|
model = self.model
|
||||||
|
device, dtype = model.device_torch, model.torch_dtype
|
||||||
|
transformer = model.transformer
|
||||||
|
|
||||||
|
latent_height = height // VAE_SCALE_FACTOR
|
||||||
|
latent_width = width // VAE_SCALE_FACTOR
|
||||||
|
channels = transformer.config.in_channels
|
||||||
|
|
||||||
|
condition_latents, condition_shapes = model.encode_condition_images(
|
||||||
|
condition_images
|
||||||
|
)
|
||||||
|
|
||||||
|
if latents is None:
|
||||||
|
# randn_tensor, not torch.randn: the caller's generator may be a cpu one
|
||||||
|
latents = randn_tensor(
|
||||||
|
(1, channels, latent_height, latent_width),
|
||||||
|
generator=generator,
|
||||||
|
device=torch.device(device),
|
||||||
|
dtype=torch.float32,
|
||||||
|
)
|
||||||
|
latents = latents.to(device, dtype=dtype)
|
||||||
|
|
||||||
|
scheduler = model.get_train_scheduler()
|
||||||
|
sigmas = np.linspace(1.0, 1 / num_inference_steps, num_inference_steps)
|
||||||
|
mu = calculate_shift(
|
||||||
|
latent_height * latent_width,
|
||||||
|
scheduler.config.get("base_image_seq_len", 256),
|
||||||
|
scheduler.config.get("max_image_seq_len", 8192),
|
||||||
|
scheduler.config.get("base_shift", 0.5),
|
||||||
|
scheduler.config.get("max_shift", 0.9),
|
||||||
|
)
|
||||||
|
scheduler.set_timesteps(sigmas=sigmas, device=device, mu=mu)
|
||||||
|
scheduler.set_begin_index(0)
|
||||||
|
|
||||||
|
# 2.1 is meant to be sampled without guidance; a scale of 1 skips it
|
||||||
|
do_cfg = guidance_scale > 1.0 and unconditional_embeds is not None
|
||||||
|
cond = model.pad_prompt_embeds(conditional_embeds)
|
||||||
|
uncond = model.pad_prompt_embeds(unconditional_embeds) if do_cfg else None
|
||||||
|
|
||||||
|
for timestep in scheduler.timesteps:
|
||||||
|
t = timestep.expand(latents.shape[0]).to(device, dtype=dtype) / 1000
|
||||||
|
noise_pred = run_transformer(
|
||||||
|
transformer,
|
||||||
|
latents,
|
||||||
|
t,
|
||||||
|
*cond,
|
||||||
|
condition_latents=condition_latents,
|
||||||
|
condition_shapes=condition_shapes,
|
||||||
|
)
|
||||||
|
if do_cfg:
|
||||||
|
uncond_pred = run_transformer(
|
||||||
|
transformer,
|
||||||
|
latents,
|
||||||
|
t,
|
||||||
|
*uncond,
|
||||||
|
condition_latents=condition_latents,
|
||||||
|
condition_shapes=condition_shapes,
|
||||||
|
)
|
||||||
|
noise_pred = uncond_pred + guidance_scale * (noise_pred - uncond_pred)
|
||||||
|
|
||||||
|
latents = scheduler.step(
|
||||||
|
noise_pred.to(torch.float32),
|
||||||
|
timestep,
|
||||||
|
latents.to(torch.float32),
|
||||||
|
return_dict=False,
|
||||||
|
)[0].to(dtype)
|
||||||
|
|
||||||
|
return model.decode_to_images(latents)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Qwen3-VL-8B conditioning stack for Qwen-Image 2.1.
|
||||||
|
|
||||||
|
The Comfy-Org repack keeps the transformers key layout except that the language
|
||||||
|
tower sits directly under `model.` instead of `model.language_model.`, so the
|
||||||
|
single-file load only needs that prefix rewrite before the toolkit's comfy
|
||||||
|
quantization importer attaches the int8/convrot layers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from toolkit.models.v2.text_encoders.qwen3_vl import Qwen3VLTextEncoder
|
||||||
|
|
||||||
|
# comfy prefix -> transformers prefix for the language tower
|
||||||
|
_COMFY_PREFIXES = (
|
||||||
|
("model.layers.", "model.language_model.layers."),
|
||||||
|
("model.embed_tokens.", "model.language_model.embed_tokens."),
|
||||||
|
("model.norm.", "model.language_model.norm."),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QwenImage21TextEncoder(Qwen3VLTextEncoder):
|
||||||
|
aitk_config_repo = "Qwen/Qwen-Image-2.1"
|
||||||
|
aitk_tokenizer_subfolder = "processor"
|
||||||
|
aitk_processor_repo = "Qwen/Qwen-Image-2.1"
|
||||||
|
aitk_processor_subfolder = "processor"
|
||||||
|
|
||||||
|
aitk_comfy_repo = "Comfy-Org/Qwen-Image-2.1"
|
||||||
|
# convrot8 first: it is the toolkit's default qtype, so a matching
|
||||||
|
# pre-quantized file attaches as-is. The w4a8 file is a different backend
|
||||||
|
# and would have to be dequantized, so it is not a candidate.
|
||||||
|
_COMFY_FILES = [
|
||||||
|
"text_encoders/qwen3vl_8b_int8_convrot.safetensors",
|
||||||
|
"text_encoders/qwen3vl_8b_bf16.safetensors",
|
||||||
|
]
|
||||||
|
aitk_comfy_weight_names = {
|
||||||
|
("Comfy-Org/Qwen-Image-2.1", "text_encoder"): _COMFY_FILES,
|
||||||
|
("Qwen/Qwen-Image-2.1", "text_encoder"): _COMFY_FILES,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def convert_state_dict_on_load(cls, state_dict):
|
||||||
|
if not any(k.startswith("model.layers.") for k in state_dict):
|
||||||
|
return state_dict
|
||||||
|
converted = {}
|
||||||
|
for key, value in state_dict.items():
|
||||||
|
for comfy_prefix, hf_prefix in _COMFY_PREFIXES:
|
||||||
|
if key.startswith(comfy_prefix):
|
||||||
|
key = hf_prefix + key[len(comfy_prefix) :]
|
||||||
|
break
|
||||||
|
converted[key] = value
|
||||||
|
return converted
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def convert_state_dict_on_save(cls, state_dict):
|
||||||
|
converted = {}
|
||||||
|
for key, value in state_dict.items():
|
||||||
|
for comfy_prefix, hf_prefix in _COMFY_PREFIXES:
|
||||||
|
if key.startswith(hf_prefix):
|
||||||
|
key = comfy_prefix + key[len(hf_prefix) :]
|
||||||
|
break
|
||||||
|
converted[key] = value
|
||||||
|
return converted
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -533,6 +533,41 @@ export const AI_TOOLKIT_UI_MODELS: ModelArch[] = [
|
|||||||
"uint3|ostris/accuracy_recovery_adapters/qwen_image_edit_2511_torchao_uint3.safetensors",
|
"uint3|ostris/accuracy_recovery_adapters/qwen_image_edit_2511_torchao_uint3.safetensors",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "qwen_image_2",
|
||||||
|
label: "Qwen-Image-2.1",
|
||||||
|
group: "image",
|
||||||
|
defaults: {
|
||||||
|
// default updates when [selected, unselected] in the UI
|
||||||
|
"config.process[0].model.name_or_path": [
|
||||||
|
"Comfy-Org/Qwen-Image-2.1",
|
||||||
|
defaultNameOrPath,
|
||||||
|
],
|
||||||
|
"config.process[0].model.quantize": [true, false],
|
||||||
|
"config.process[0].model.quantize_te": [true, false],
|
||||||
|
"config.process[0].model.low_vram": [true, false],
|
||||||
|
"config.process[0].train.unload_text_encoder": [false, false],
|
||||||
|
"config.process[0].sample.sampler": ["flowmatch", "flowmatch"],
|
||||||
|
"config.process[0].train.noise_scheduler": ["flowmatch", "flowmatch"],
|
||||||
|
"config.process[0].train.timestep_type": ["shift", "sigmoid"],
|
||||||
|
// the Comfy-Org weights are pre-quantized int8 convrot; these qtypes
|
||||||
|
// match the checkpoints exactly, so the load is unchanged. Picking a
|
||||||
|
// different qtype re-quantizes layer by layer into that format.
|
||||||
|
"config.process[0].model.qtype": ["convrot8", "qfloat8"],
|
||||||
|
"config.process[0].model.qtype_te": ["convrot8", "qfloat8"],
|
||||||
|
// 2.1 is meant to be sampled without guidance
|
||||||
|
"config.process[0].sample.guidance_scale": [1.0, 4.0],
|
||||||
|
},
|
||||||
|
disableSections: ["network.conv", "train.unload_text_encoder"],
|
||||||
|
// one model: it edits when the dataset has control paths, and is plain
|
||||||
|
// text to image when it does not
|
||||||
|
additionalSections: [
|
||||||
|
"datasets.multi_control_paths",
|
||||||
|
"sample.multi_ctrl_imgs",
|
||||||
|
"model.low_vram",
|
||||||
|
"model.layer_offloading",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "hidream",
|
name: "hidream",
|
||||||
label: "HiDream",
|
label: "HiDream",
|
||||||
|
|||||||
@@ -178,6 +178,13 @@ MODEL_TESTS = {
|
|||||||
"sample": {**IMG, "num_inference_steps": 20, "guidance_scale": 4.0},
|
"sample": {**IMG, "num_inference_steps": 20, "guidance_scale": 4.0},
|
||||||
"needs_control_image": True,
|
"needs_control_image": True,
|
||||||
},
|
},
|
||||||
|
"qwen_image_2": {
|
||||||
|
"model": {"name_or_path": "Comfy-Org/Qwen-Image-2.1", "quantize": True, "quantize_te": True},
|
||||||
|
# 2.1 is meant to be sampled without guidance. One arch: the control
|
||||||
|
# image exercises the reference path, which plain t2i is a subset of
|
||||||
|
"sample": {**IMG, "num_inference_steps": 20, "guidance_scale": 1.0},
|
||||||
|
"needs_control_image": True,
|
||||||
|
},
|
||||||
# ---- legacy monolith archs (components adopted into v2 on load) ----
|
# ---- legacy monolith archs (components adopted into v2 on load) ----
|
||||||
"sd1": {
|
"sd1": {
|
||||||
"model": {"name_or_path": "stable-diffusion-v1-5/stable-diffusion-v1-5"},
|
"model": {"name_or_path": "stable-diffusion-v1-5/stable-diffusion-v1-5"},
|
||||||
|
|||||||
@@ -672,6 +672,11 @@ class OstrisModelMixin:
|
|||||||
(and importer-assigned bias) legitimately report as missing keys."""
|
(and importer-assigned bias) legitimately report as missing keys."""
|
||||||
from toolkit.util.ostris_quant import OstrisLinear
|
from toolkit.util.ostris_quant import OstrisLinear
|
||||||
|
|
||||||
|
# Anything the importer already materialized is not missing, it is
|
||||||
|
# done: a convrot embedding becomes a plain nn.Embedding holding the
|
||||||
|
# unrotated table, so its key is gone from the state dict on purpose.
|
||||||
|
# Taken before the load, while everything else is still on meta.
|
||||||
|
preloaded = {n for n, p in model.named_parameters() if not p.is_meta}
|
||||||
result = model.load_state_dict(state_dict, assign=True, strict=False)
|
result = model.load_state_dict(state_dict, assign=True, strict=False)
|
||||||
quantized_param_keys = set()
|
quantized_param_keys = set()
|
||||||
for name, m in model.named_modules():
|
for name, m in model.named_modules():
|
||||||
@@ -679,7 +684,11 @@ class OstrisModelMixin:
|
|||||||
quantized_param_keys.add(f"{name}.weight")
|
quantized_param_keys.add(f"{name}.weight")
|
||||||
if m.bias is not None:
|
if m.bias is not None:
|
||||||
quantized_param_keys.add(f"{name}.bias")
|
quantized_param_keys.add(f"{name}.bias")
|
||||||
bad_missing = [k for k in result.missing_keys if k not in quantized_param_keys]
|
bad_missing = [
|
||||||
|
k
|
||||||
|
for k in result.missing_keys
|
||||||
|
if k not in quantized_param_keys and k not in preloaded
|
||||||
|
]
|
||||||
if bad_missing or result.unexpected_keys:
|
if bad_missing or result.unexpected_keys:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{type(model).__name__} load mismatch: missing {bad_missing[:8]}, "
|
f"{type(model).__name__} load mismatch: missing {bad_missing[:8]}, "
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
VERSION = "0.13.16"
|
VERSION = "0.13.17"
|
||||||
Reference in new issue
Block a user