cddb07a176
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled
323 lines
14 KiB
Python
323 lines
14 KiB
Python
from typing import Literal
|
|
|
|
import torch
|
|
from PIL import Image as PILImage
|
|
|
|
from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
|
|
from invokeai.app.invocations.fields import (
|
|
FieldDescriptions,
|
|
ImageField,
|
|
Input,
|
|
InputField,
|
|
UIComponent,
|
|
)
|
|
from invokeai.app.invocations.model import QwenVLEncoderField
|
|
from invokeai.app.invocations.primitives import QwenImageConditioningOutput
|
|
from invokeai.app.services.shared.invocation_context import InvocationContext
|
|
from invokeai.backend.model_manager.load.model_cache.utils import get_effective_device
|
|
from invokeai.backend.stable_diffusion.diffusion.conditioning_data import (
|
|
ConditioningFieldData,
|
|
QwenImageConditioningInfo,
|
|
)
|
|
|
|
# Prompt templates and drop indices for the two Qwen Image model modes.
|
|
# These are taken directly from the diffusers pipelines.
|
|
|
|
# Image editing mode (QwenImagePipeline)
|
|
_EDIT_SYSTEM_PROMPT = (
|
|
"Describe the key features of the input image (color, shape, size, texture, objects, background), "
|
|
"then explain how the user's text instruction should alter or modify the image. "
|
|
"Generate a new image that meets the user's requirements while maintaining consistency "
|
|
"with the original input where appropriate."
|
|
)
|
|
_EDIT_DROP_IDX = 64
|
|
|
|
# Text-to-image mode (QwenImagePipeline)
|
|
_GENERATE_SYSTEM_PROMPT = (
|
|
"Describe the image by detailing the color, shape, size, texture, quantity, "
|
|
"text, spatial relationships of the objects and background:"
|
|
)
|
|
_GENERATE_DROP_IDX = 34
|
|
|
|
_IMAGE_PLACEHOLDER = "<|vision_start|><|image_pad|><|vision_end|>"
|
|
|
|
|
|
def _build_prompt(user_prompt: str, num_images: int) -> str:
|
|
"""Build the full prompt with the appropriate template based on whether reference images are provided."""
|
|
if num_images > 0:
|
|
# Edit mode: include vision placeholders for reference images
|
|
image_tokens = _IMAGE_PLACEHOLDER * num_images
|
|
return (
|
|
f"<|im_start|>system\n{_EDIT_SYSTEM_PROMPT}<|im_end|>\n"
|
|
f"<|im_start|>user\n{image_tokens}{user_prompt}<|im_end|>\n"
|
|
"<|im_start|>assistant\n"
|
|
)
|
|
else:
|
|
# Generate mode: text-only prompt
|
|
return (
|
|
f"<|im_start|>system\n{_GENERATE_SYSTEM_PROMPT}<|im_end|>\n"
|
|
f"<|im_start|>user\n{user_prompt}<|im_end|>\n"
|
|
"<|im_start|>assistant\n"
|
|
)
|
|
|
|
|
|
@invocation(
|
|
"qwen_image_text_encoder",
|
|
title="Prompt - Qwen Image",
|
|
tags=["prompt", "conditioning", "qwen_image"],
|
|
category="conditioning",
|
|
version="1.2.0",
|
|
classification=Classification.Prototype,
|
|
)
|
|
class QwenImageTextEncoderInvocation(BaseInvocation):
|
|
"""Encodes text and reference images for Qwen Image using Qwen2.5-VL."""
|
|
|
|
prompt: str = InputField(description="Text prompt describing the desired edit.", ui_component=UIComponent.Textarea)
|
|
reference_images: list[ImageField] = InputField(
|
|
default=[],
|
|
description="Reference images to guide the edit. The model can use multiple reference images.",
|
|
)
|
|
qwen_vl_encoder: QwenVLEncoderField = InputField(
|
|
title="Qwen VL Encoder",
|
|
description=FieldDescriptions.qwen_vl_encoder,
|
|
input=Input.Connection,
|
|
)
|
|
quantization: Literal["none", "int8", "nf4"] = InputField(
|
|
default="none",
|
|
description="Quantize the Qwen VL encoder to reduce VRAM usage. "
|
|
"'nf4' (4-bit) saves the most memory, 'int8' (8-bit) is a middle ground.",
|
|
)
|
|
|
|
@staticmethod
|
|
def _resize_for_vl_encoder(image: PILImage.Image, target_pixels: int = 512 * 512) -> PILImage.Image:
|
|
"""Resize image to fit within target_pixels while preserving aspect ratio.
|
|
|
|
Matches the diffusers pipeline's calculate_dimensions logic: the image is resized
|
|
so its total pixel count is approximately target_pixels, with dimensions rounded to
|
|
multiples of 32. This prevents large images from producing too many vision tokens
|
|
which can overwhelm the text prompt.
|
|
"""
|
|
w, h = image.size
|
|
aspect = w / h
|
|
# Compute dimensions that preserve aspect ratio at ~target_pixels total
|
|
new_w = int((target_pixels * aspect) ** 0.5)
|
|
new_h = int(target_pixels / new_w)
|
|
# Round to multiples of 32
|
|
new_w = max(32, (new_w // 32) * 32)
|
|
new_h = max(32, (new_h // 32) * 32)
|
|
if new_w != w or new_h != h:
|
|
image = image.resize((new_w, new_h), resample=PILImage.LANCZOS)
|
|
return image
|
|
|
|
@torch.no_grad()
|
|
def invoke(self, context: InvocationContext) -> QwenImageConditioningOutput:
|
|
# Load and resize reference images to ~1M pixels (matching diffusers pipeline)
|
|
pil_images: list[PILImage.Image] = []
|
|
for img_field in self.reference_images:
|
|
pil_img = context.images.get_pil(img_field.image_name)
|
|
pil_img = self._resize_for_vl_encoder(pil_img.convert("RGB"))
|
|
pil_images.append(pil_img)
|
|
|
|
prompt_embeds, prompt_mask = self._encode(context, pil_images)
|
|
prompt_embeds = prompt_embeds.detach().to("cpu")
|
|
prompt_mask = prompt_mask.detach().to("cpu") if prompt_mask is not None else None
|
|
|
|
conditioning_data = ConditioningFieldData(
|
|
conditionings=[QwenImageConditioningInfo(prompt_embeds=prompt_embeds, prompt_embeds_mask=prompt_mask)]
|
|
)
|
|
conditioning_name = context.conditioning.save(conditioning_data)
|
|
return QwenImageConditioningOutput.build(conditioning_name)
|
|
|
|
def _encode(
|
|
self, context: InvocationContext, images: list[PILImage.Image]
|
|
) -> tuple[torch.Tensor, torch.Tensor | None]:
|
|
"""Encode text prompt and reference images using Qwen2.5-VL.
|
|
|
|
Matches the diffusers QwenImagePipeline._get_qwen_prompt_embeds logic:
|
|
1. Format prompt with the edit-specific system template
|
|
2. Run through Qwen2.5-VL to get hidden states
|
|
3. Extract valid (non-padding) tokens and drop the system prefix
|
|
4. Return padded embeddings + attention mask
|
|
"""
|
|
from transformers import AutoTokenizer, Qwen2_5_VLProcessor
|
|
|
|
try:
|
|
from transformers import Qwen2_5_VLImageProcessor as _ImageProcessorCls
|
|
except ImportError:
|
|
from transformers.models.qwen2_vl.image_processing_qwen2_vl import ( # type: ignore[no-redef]
|
|
Qwen2VLImageProcessor as _ImageProcessorCls,
|
|
)
|
|
|
|
try:
|
|
from transformers import Qwen2_5_VLVideoProcessor as _VideoProcessorCls
|
|
except ImportError:
|
|
from transformers.models.qwen2_vl.video_processing_qwen2_vl import ( # type: ignore[no-redef]
|
|
Qwen2VLVideoProcessor as _VideoProcessorCls,
|
|
)
|
|
|
|
# Format the prompt with one vision placeholder per reference image
|
|
text = _build_prompt(self.prompt, len(images))
|
|
|
|
# Build the processor
|
|
tokenizer_config = context.models.get_config(self.qwen_vl_encoder.tokenizer)
|
|
model_root = context.models.get_absolute_path(tokenizer_config)
|
|
|
|
# Single-file checkpoints (e.g. ComfyUI fp8_scaled): model_root is the
|
|
# safetensors file itself, so there's no tokenizer/processor folder
|
|
# alongside it. Fall back to the canonical Qwen2.5-VL repo on HF (small
|
|
# ~10 MB download for tokenizer+processor configs, cached for offline use).
|
|
if model_root.is_file():
|
|
HF_REPO = "Qwen/Qwen2.5-VL-7B-Instruct"
|
|
try:
|
|
tokenizer = AutoTokenizer.from_pretrained(HF_REPO, local_files_only=True)
|
|
except OSError:
|
|
tokenizer = AutoTokenizer.from_pretrained(HF_REPO)
|
|
try:
|
|
image_processor = _ImageProcessorCls.from_pretrained(HF_REPO, local_files_only=True)
|
|
except OSError:
|
|
try:
|
|
image_processor = _ImageProcessorCls.from_pretrained(HF_REPO)
|
|
except Exception:
|
|
image_processor = _ImageProcessorCls()
|
|
else:
|
|
tokenizer_dir = model_root / "tokenizer"
|
|
tokenizer = AutoTokenizer.from_pretrained(str(tokenizer_dir), local_files_only=True)
|
|
|
|
image_processor = None
|
|
for search_dir in [model_root / "processor", tokenizer_dir, model_root, model_root / "image_processor"]:
|
|
if (search_dir / "preprocessor_config.json").exists():
|
|
image_processor = _ImageProcessorCls.from_pretrained(str(search_dir), local_files_only=True)
|
|
break
|
|
if image_processor is None:
|
|
image_processor = _ImageProcessorCls()
|
|
|
|
processor = Qwen2_5_VLProcessor(
|
|
tokenizer=tokenizer,
|
|
image_processor=image_processor,
|
|
video_processor=_VideoProcessorCls(),
|
|
)
|
|
|
|
context.util.signal_progress("Running Qwen2.5-VL text/vision encoder")
|
|
|
|
if self.quantization != "none":
|
|
text_encoder, device, cleanup = self._load_quantized_encoder(context)
|
|
else:
|
|
text_encoder, device, cleanup = self._load_cached_encoder(context)
|
|
|
|
try:
|
|
model_inputs = processor(
|
|
text=[text],
|
|
images=images if images else None,
|
|
padding=True,
|
|
return_tensors="pt",
|
|
).to(device=device)
|
|
|
|
outputs = text_encoder(
|
|
input_ids=model_inputs.input_ids,
|
|
attention_mask=model_inputs.attention_mask,
|
|
pixel_values=getattr(model_inputs, "pixel_values", None),
|
|
image_grid_thw=getattr(model_inputs, "image_grid_thw", None),
|
|
output_hidden_states=True,
|
|
)
|
|
|
|
# Use last hidden state (matching diffusers pipeline)
|
|
hidden_states = outputs.hidden_states[-1]
|
|
|
|
# Extract valid (non-padding) tokens using the attention mask,
|
|
# then drop the system prompt prefix tokens.
|
|
# The drop index differs between edit mode (64) and generate mode (34).
|
|
drop_idx = _EDIT_DROP_IDX if images else _GENERATE_DROP_IDX
|
|
|
|
attn_mask = model_inputs.attention_mask
|
|
bool_mask = attn_mask.bool()
|
|
valid_lengths = bool_mask.sum(dim=1)
|
|
selected = hidden_states[bool_mask]
|
|
split_hidden = torch.split(selected, valid_lengths.tolist(), dim=0)
|
|
|
|
# Drop system prefix tokens and build padded output
|
|
trimmed = [h[drop_idx:] for h in split_hidden]
|
|
attn_mask_list = [torch.ones(h.size(0), dtype=torch.long, device=device) for h in trimmed]
|
|
max_seq_len = max(h.size(0) for h in trimmed)
|
|
|
|
prompt_embeds = torch.stack(
|
|
[torch.cat([h, h.new_zeros(max_seq_len - h.size(0), h.size(1))]) for h in trimmed]
|
|
)
|
|
encoder_attention_mask = torch.stack(
|
|
[torch.cat([m, m.new_zeros(max_seq_len - m.size(0))]) for m in attn_mask_list]
|
|
)
|
|
|
|
prompt_embeds = prompt_embeds.to(dtype=torch.bfloat16)
|
|
finally:
|
|
if cleanup is not None:
|
|
cleanup()
|
|
|
|
# If all tokens are valid (no padding), mask is not needed
|
|
if encoder_attention_mask.all():
|
|
encoder_attention_mask = None
|
|
|
|
return prompt_embeds, encoder_attention_mask
|
|
|
|
def _load_cached_encoder(self, context: InvocationContext):
|
|
"""Load the text encoder through the model cache (no quantization)."""
|
|
from transformers import Qwen2_5_VLForConditionalGeneration
|
|
|
|
text_encoder_info = context.models.load(self.qwen_vl_encoder.text_encoder)
|
|
ctx = text_encoder_info.model_on_device()
|
|
_, text_encoder = ctx.__enter__()
|
|
device = get_effective_device(text_encoder)
|
|
assert isinstance(text_encoder, Qwen2_5_VLForConditionalGeneration)
|
|
return text_encoder, device, lambda: ctx.__exit__(None, None, None)
|
|
|
|
def _load_quantized_encoder(self, context: InvocationContext):
|
|
"""Load the text encoder with BitsAndBytes quantization, bypassing the model cache.
|
|
|
|
BnB-quantized models are pinned to GPU and can't be moved between devices,
|
|
so they can't go through the standard model cache. The model is loaded fresh
|
|
each time and freed after use via the cleanup callback.
|
|
"""
|
|
import gc
|
|
import warnings
|
|
|
|
from transformers import BitsAndBytesConfig, Qwen2_5_VLForConditionalGeneration
|
|
|
|
encoder_config = context.models.get_config(self.qwen_vl_encoder.text_encoder)
|
|
model_root = context.models.get_absolute_path(encoder_config)
|
|
if model_root.is_file():
|
|
# Single-file checkpoint (e.g. ComfyUI fp8_scaled): BnB can't load from
|
|
# a single file, and the checkpoint is already FP8-compressed anyway.
|
|
# Fall back to the cached path; the user effectively gets fp8 instead of
|
|
# int8/nf4, which is comparable in size.
|
|
return self._load_cached_encoder(context)
|
|
encoder_path = model_root / "text_encoder"
|
|
|
|
if self.quantization == "nf4":
|
|
bnb_config = BitsAndBytesConfig(
|
|
load_in_4bit=True,
|
|
bnb_4bit_compute_dtype=torch.bfloat16,
|
|
bnb_4bit_quant_type="nf4",
|
|
)
|
|
else: # int8
|
|
bnb_config = BitsAndBytesConfig(load_in_8bit=True)
|
|
|
|
context.util.signal_progress("Loading Qwen2.5-VL encoder (quantized)")
|
|
with warnings.catch_warnings():
|
|
# BnB int8 internally casts bfloat16→float16; the warning is harmless
|
|
warnings.filterwarnings("ignore", message="MatMul8bitLt.*cast.*float16")
|
|
text_encoder = Qwen2_5_VLForConditionalGeneration.from_pretrained(
|
|
str(encoder_path),
|
|
quantization_config=bnb_config,
|
|
device_map="auto",
|
|
torch_dtype=torch.bfloat16,
|
|
local_files_only=True,
|
|
)
|
|
|
|
device = next(text_encoder.parameters()).device
|
|
|
|
def cleanup():
|
|
nonlocal text_encoder
|
|
del text_encoder
|
|
gc.collect()
|
|
torch.cuda.empty_cache()
|
|
|
|
return text_encoder, device, cleanup
|