Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:22:06 +08:00

560 lines
26 KiB
Python

import math
from contextlib import ExitStack
from typing import Callable, ClassVar, Iterator, Optional, Tuple
import torch
import torchvision.transforms as tv_transforms
from diffusers.models.transformers.transformer_qwenimage import QwenImageTransformer2DModel
from torchvision.transforms.functional import resize as tv_resize
from tqdm import tqdm
from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation
from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR
from invokeai.app.invocations.fields import (
DenoiseMaskField,
FieldDescriptions,
Input,
InputField,
LatentsField,
QwenImageConditioningField,
WithBoard,
WithMetadata,
)
from invokeai.app.invocations.model import TransformerField
from invokeai.app.invocations.primitives import LatentsOutput
from invokeai.app.services.shared.invocation_context import InvocationContext
from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat
from invokeai.backend.patches.layer_patcher import LayerPatcher
from invokeai.backend.patches.lora_conversions.qwen_image_lora_constants import (
QWEN_IMAGE_EDIT_LORA_TRANSFORMER_PREFIX,
)
from invokeai.backend.patches.model_patch_raw import ModelPatchRaw
from invokeai.backend.rectified_flow.rectified_flow_inpaint_extension import RectifiedFlowInpaintExtension
from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState
from invokeai.backend.stable_diffusion.diffusion.conditioning_data import QwenImageConditioningInfo
from invokeai.backend.util.devices import TorchDevice
@invocation(
"qwen_image_denoise",
title="Denoise - Qwen Image",
tags=["image", "qwen_image"],
category="image",
version="1.0.0",
classification=Classification.Prototype,
)
class QwenImageDenoiseInvocation(BaseInvocation, WithMetadata, WithBoard):
"""Run the denoising process with a Qwen Image model."""
# If latents is provided, this means we are doing image-to-image.
latents: Optional[LatentsField] = InputField(
default=None, description=FieldDescriptions.latents, input=Input.Connection
)
# Reference image latents (encoded through VAE) to concatenate with noisy latents.
reference_latents: Optional[LatentsField] = InputField(
default=None,
description="Reference image latents to guide generation. Encoded through the VAE.",
input=Input.Connection,
)
# denoise_mask is used for image-to-image inpainting. Only the masked region is modified.
denoise_mask: Optional[DenoiseMaskField] = InputField(
default=None, description=FieldDescriptions.denoise_mask, input=Input.Connection
)
denoising_start: float = InputField(default=0.0, ge=0, le=1, description=FieldDescriptions.denoising_start)
denoising_end: float = InputField(default=1.0, ge=0, le=1, description=FieldDescriptions.denoising_end)
transformer: TransformerField = InputField(
description=FieldDescriptions.qwen_image_model, input=Input.Connection, title="Transformer"
)
positive_conditioning: QwenImageConditioningField = InputField(
description=FieldDescriptions.positive_cond, input=Input.Connection
)
negative_conditioning: Optional[QwenImageConditioningField] = InputField(
default=None, description=FieldDescriptions.negative_cond, input=Input.Connection
)
cfg_scale: float | list[float] = InputField(default=4.0, description=FieldDescriptions.cfg_scale, title="CFG Scale")
width: int = InputField(default=1024, multiple_of=16, description="Width of the generated image.")
height: int = InputField(default=1024, multiple_of=16, description="Height of the generated image.")
steps: int = InputField(default=40, gt=0, description=FieldDescriptions.steps)
seed: int = InputField(default=0, description="Randomness seed for reproducibility.")
shift: Optional[float] = InputField(
default=None,
description="Override the sigma schedule shift. "
"When set, uses a fixed shift (e.g. 3.0 for Lightning LoRAs) instead of the default dynamic shifting. "
"Leave unset for the base model's default schedule.",
)
@torch.no_grad()
def invoke(self, context: InvocationContext) -> LatentsOutput:
latents = self._run_diffusion(context)
latents = latents.detach().to("cpu")
name = context.tensors.save(tensor=latents)
return LatentsOutput.build(latents_name=name, latents=latents, seed=None)
def _prep_inpaint_mask(self, context: InvocationContext, latents: torch.Tensor) -> torch.Tensor | None:
if self.denoise_mask is None:
return None
mask = context.tensors.load(self.denoise_mask.mask_name)
mask = 1.0 - mask
_, _, latent_height, latent_width = latents.shape
mask = tv_resize(
img=mask,
size=[latent_height, latent_width],
interpolation=tv_transforms.InterpolationMode.BILINEAR,
antialias=False,
)
mask = mask.to(device=latents.device, dtype=latents.dtype)
return mask
def _load_text_conditioning(
self,
context: InvocationContext,
conditioning_name: str,
dtype: torch.dtype,
device: torch.device,
) -> tuple[torch.Tensor, torch.Tensor | None]:
cond_data = context.conditioning.load(conditioning_name)
assert len(cond_data.conditionings) == 1
conditioning = cond_data.conditionings[0]
assert isinstance(conditioning, QwenImageConditioningInfo)
conditioning = conditioning.to(dtype=dtype, device=device)
return conditioning.prompt_embeds, conditioning.prompt_embeds_mask
def _get_noise(
self,
batch_size: int,
num_channels_latents: int,
height: int,
width: int,
dtype: torch.dtype,
device: torch.device,
seed: int,
) -> torch.Tensor:
rand_device = "cpu"
rand_dtype = torch.float32
return torch.randn(
batch_size,
num_channels_latents,
int(height) // LATENT_SCALE_FACTOR,
int(width) // LATENT_SCALE_FACTOR,
device=rand_device,
dtype=rand_dtype,
generator=torch.Generator(device=rand_device).manual_seed(seed),
).to(device=device, dtype=dtype)
def _prepare_cfg_scale(self, num_timesteps: int) -> list[float]:
if isinstance(self.cfg_scale, float):
cfg_scale = [self.cfg_scale] * num_timesteps
elif isinstance(self.cfg_scale, list):
assert len(self.cfg_scale) == num_timesteps
cfg_scale = self.cfg_scale
else:
raise ValueError(f"Invalid CFG scale type: {type(self.cfg_scale)}")
return cfg_scale
@staticmethod
def _pack_latents(
latents: torch.Tensor, batch_size: int, num_channels: int, height: int, width: int
) -> torch.Tensor:
"""Pack 4D latents (B, C, H, W) into 2x2-patched 3D (B, H/2*W/2, C*4)."""
latents = latents.view(batch_size, num_channels, height // 2, 2, width // 2, 2)
latents = latents.permute(0, 2, 4, 1, 3, 5)
latents = latents.reshape(batch_size, (height // 2) * (width // 2), num_channels * 4)
return latents
@staticmethod
def _unpack_latents(latents: torch.Tensor, height: int, width: int) -> torch.Tensor:
"""Unpack 3D patched latents (B, seq, C*4) back to 4D (B, C, H, W)."""
batch_size, _num_patches, channels = latents.shape
# height/width are in latent space; they must be divisible by 2 for packing
h = 2 * (height // 2)
w = 2 * (width // 2)
latents = latents.view(batch_size, h // 2, w // 2, channels // 4, 2, 2)
latents = latents.permute(0, 3, 1, 4, 2, 5)
latents = latents.reshape(batch_size, channels // 4, h, w)
return latents
@staticmethod
def _align_ref_latent_dims(rh: int, rw: int) -> tuple[int, int]:
"""Trim reference latent spatial dims to even values for 2x2 packing.
Raises ValueError if the aligned dims would be < 2 (i.e., the reference
latent is too small to produce any valid tokens).
"""
rh_aligned = rh - (rh % 2)
rw_aligned = rw - (rw % 2)
if rh_aligned < 2 or rw_aligned < 2:
raise ValueError(
f"Reference latent spatial dims must be >= 2 after even alignment; "
f"got ({rh_aligned}, {rw_aligned}) from input shape ({rh}, {rw}). "
"Ensure the reference image is at least 16 pixels in each dimension."
)
return rh_aligned, rw_aligned
@staticmethod
def _build_img_shapes(
latent_height: int,
latent_width: int,
ref_latent_height: int | None = None,
ref_latent_width: int | None = None,
) -> list[list[tuple[int, int, int]]]:
"""Build the img_shapes argument for the transformer.
The reference segment (if present) must use its own dims so QwenEmbedRope's
spatial frequencies position ref tokens distinctly from noisy tokens —
otherwise reference content bleeds into the generation as a ghost.
"""
shapes: list[tuple[int, int, int]] = [(1, latent_height // 2, latent_width // 2)]
if ref_latent_height is not None and ref_latent_width is not None:
shapes.append((1, ref_latent_height // 2, ref_latent_width // 2))
return [shapes]
# diffusers' QwenImageEdit(Plus)Pipeline VAE_IMAGE_SIZE = 1024 * 1024 pixels;
# ref images are resized to this area (preserving aspect, snapped to multiples
# of 32) before VAE encoding. We mirror this clamp in latent space so direct
# backend callers — whose i2l may not pass explicit width/height — don't feed
# the transformer an out-of-distribution reference sequence length (which
# also causes a VRAM spike for large inputs).
_REF_TARGET_PIXEL_AREA: ClassVar[int] = 1024 * 1024
_VAE_SCALE_FACTOR: ClassVar[int] = 8
@classmethod
def _maybe_clamp_ref_latent_size(cls, ref_latents: torch.Tensor) -> torch.Tensor:
"""Bilinear-downscale the reference latent if it exceeds diffusers'
VAE_IMAGE_SIZE budget.
Returns the latent unchanged if it's already within budget.
"""
_, _, rh, rw = ref_latents.shape
target_cells = cls._REF_TARGET_PIXEL_AREA // (cls._VAE_SCALE_FACTOR**2)
if rh * rw <= target_cells:
return ref_latents
aspect = rw / rh
target_w_px = math.sqrt(cls._REF_TARGET_PIXEL_AREA * aspect)
target_h_px = target_w_px / aspect
target_w_px = max(32, round(target_w_px / 32) * 32)
target_h_px = max(32, round(target_h_px / 32) * 32)
target_rh = target_h_px // cls._VAE_SCALE_FACTOR
target_rw = target_w_px // cls._VAE_SCALE_FACTOR
return torch.nn.functional.interpolate(
ref_latents, size=(target_rh, target_rw), mode="bilinear", antialias=False
)
def _run_diffusion(self, context: InvocationContext):
inference_dtype = torch.bfloat16
device = TorchDevice.choose_torch_device()
transformer_info = context.models.load(self.transformer.transformer)
assert isinstance(transformer_info.model, QwenImageTransformer2DModel)
# Load conditioning
pos_prompt_embeds, pos_prompt_mask = self._load_text_conditioning(
context=context,
conditioning_name=self.positive_conditioning.conditioning_name,
dtype=inference_dtype,
device=device,
)
neg_prompt_embeds = None
neg_prompt_mask = None
# Match the diffusers pipeline: only enable CFG when cfg_scale > 1 AND negative conditioning is provided.
# With cfg_scale <= 1, the negative prediction is unused, so skip it entirely.
# For per-step arrays, enable CFG if any step has scale > 1.
if isinstance(self.cfg_scale, list):
any_cfg_above_one = any(v > 1.0 for v in self.cfg_scale)
else:
any_cfg_above_one = self.cfg_scale > 1.0
do_classifier_free_guidance = self.negative_conditioning is not None and any_cfg_above_one
if do_classifier_free_guidance:
neg_prompt_embeds, neg_prompt_mask = self._load_text_conditioning(
context=context,
conditioning_name=self.negative_conditioning.conditioning_name,
dtype=inference_dtype,
device=device,
)
# Prepare the timestep / sigma schedule
patch_size = transformer_info.model.config.patch_size
assert isinstance(patch_size, int)
# Output channels is 16 (the actual latent channels)
out_channels = transformer_info.model.config.out_channels
assert isinstance(out_channels, int)
latent_height = self.height // LATENT_SCALE_FACTOR
latent_width = self.width // LATENT_SCALE_FACTOR
image_seq_len = (latent_height * latent_width) // (patch_size**2)
# Use the actual FlowMatchEulerDiscreteScheduler to compute sigmas/timesteps,
# exactly matching the diffusers pipeline.
import math
import numpy as np
from diffusers.schedulers.scheduling_flow_match_euler_discrete import FlowMatchEulerDiscreteScheduler
# Try to load the scheduler config from the model's directory (Diffusers models
# have a scheduler/ subdir). For GGUF models this path doesn't exist, so fall
# back to instantiating the scheduler with the known Qwen Image defaults.
model_path = context.models.get_absolute_path(context.models.get_config(self.transformer.transformer))
scheduler_path = model_path / "scheduler"
if scheduler_path.is_dir() and (scheduler_path / "scheduler_config.json").exists():
scheduler = FlowMatchEulerDiscreteScheduler.from_pretrained(str(scheduler_path), local_files_only=True)
else:
scheduler = FlowMatchEulerDiscreteScheduler(
use_dynamic_shifting=True,
base_shift=0.5,
max_shift=0.9,
base_image_seq_len=256,
max_image_seq_len=8192,
shift_terminal=0.02,
num_train_timesteps=1000,
time_shift_type="exponential",
)
if self.shift is not None:
# Lightning LoRA: fixed shift
mu = math.log(self.shift)
else:
# Default dynamic shifting
# Linear interpolation matching diffusers' calculate_shift
base_shift = scheduler.config.get("base_shift", 0.5)
max_shift = scheduler.config.get("max_shift", 0.9)
base_seq = scheduler.config.get("base_image_seq_len", 256)
max_seq = scheduler.config.get("max_image_seq_len", 4096)
m = (max_shift - base_shift) / (max_seq - base_seq)
b = base_shift - m * base_seq
mu = image_seq_len * m + b
init_sigmas = np.linspace(1.0, 1.0 / self.steps, self.steps).tolist()
scheduler.set_timesteps(sigmas=init_sigmas, mu=mu, device=device)
# Clip the schedule based on denoising_start/denoising_end to support img2img strength.
# The scheduler's sigmas go from high (noisy) to 0 (clean). We clip to the fractional range.
sigmas_sched = scheduler.sigmas # (N+1,) including terminal 0
if self.denoising_start > 0 or self.denoising_end < 1:
total_sigmas = len(sigmas_sched) - 1 # exclude terminal
start_idx = int(round(self.denoising_start * total_sigmas))
end_idx = int(round(self.denoising_end * total_sigmas))
sigmas_sched = sigmas_sched[start_idx : end_idx + 1] # +1 to include the next sigma for dt
# Rebuild timesteps from clipped sigmas (exclude terminal 0)
timesteps_sched = sigmas_sched[:-1] * scheduler.config.num_train_timesteps
else:
timesteps_sched = scheduler.timesteps
total_steps = len(timesteps_sched)
cfg_scale = self._prepare_cfg_scale(total_steps)
# Load initial latents if provided (for img2img)
init_latents = context.tensors.load(self.latents.latents_name) if self.latents else None
if init_latents is not None:
init_latents = init_latents.to(device=device, dtype=inference_dtype)
if init_latents.dim() == 5:
init_latents = init_latents.squeeze(2)
# Load reference image latents if provided
ref_latents = None
if self.reference_latents is not None:
ref_latents = context.tensors.load(self.reference_latents.latents_name)
ref_latents = ref_latents.to(device=device, dtype=inference_dtype)
# The VAE encoder produces 5D latents (B, C, 1, H, W); squeeze the frame dim
# so we have 4D (B, C, H, W) for packing.
if ref_latents.dim() == 5:
ref_latents = ref_latents.squeeze(2)
# Generate noise (16 channels - the output latent channels)
noise = self._get_noise(
batch_size=1,
num_channels_latents=out_channels,
height=self.height,
width=self.width,
dtype=inference_dtype,
device=device,
seed=self.seed,
)
# Prepare input latent image
if init_latents is not None:
s_0 = sigmas_sched[0].item()
latents = s_0 * noise + (1.0 - s_0) * init_latents
else:
if self.denoising_start > 1e-5:
raise ValueError("denoising_start should be 0 when initial latents are not provided.")
latents = noise
if total_steps <= 0:
return latents
# Pack latents into 2x2 patches: (B, C, H, W) -> (B, H/2*W/2, C*4)
latents = self._pack_latents(latents, 1, out_channels, latent_height, latent_width)
# Determine whether the model uses reference latent conditioning (zero_cond_t).
# Edit models (zero_cond_t=True) expect [noisy_patches ; ref_patches] in the sequence.
# Txt2img models (zero_cond_t=False) only take noisy patches.
has_zero_cond_t = getattr(transformer_info.model, "zero_cond_t", False) or getattr(
transformer_info.model.config, "zero_cond_t", False
)
use_ref_latents = has_zero_cond_t
ref_latents_packed = None
ref_latent_height = latent_height
ref_latent_width = latent_width
if use_ref_latents:
if ref_latents is not None:
# Defense-in-depth: backend callers (direct API, older graph JSON)
# may wire qwen_image_i2l without explicit width/height, producing
# a native-resolution reference latent. Clamp here so the
# transformer always sees an in-distribution sequence length.
ref_latents = self._maybe_clamp_ref_latent_size(ref_latents)
_, _, rh, rw = ref_latents.shape
ref_latent_height, ref_latent_width = self._align_ref_latent_dims(rh, rw)
if ref_latent_height != rh or ref_latent_width != rw:
ref_latents = ref_latents[..., :ref_latent_height, :ref_latent_width]
else:
# No reference image provided — use zeros so the model still gets the
# expected sequence layout.
ref_latents = torch.zeros(
1, out_channels, latent_height, latent_width, device=device, dtype=inference_dtype
)
ref_latents_packed = self._pack_latents(ref_latents, 1, out_channels, ref_latent_height, ref_latent_width)
# img_shapes tells the transformer the spatial layout of patches. The reference
# segment must use the reference latent's own dimensions so RoPE positions it
# distinctly from the noisy latent — otherwise the two segments share spatial
# positional encoding and the model can't disentangle them, producing a
# ghost/doubling artifact across the whole frame. Matches diffusers'
# QwenImageEditPipeline / QwenImageEditPlusPipeline.
if use_ref_latents:
img_shapes = self._build_img_shapes(latent_height, latent_width, ref_latent_height, ref_latent_width)
else:
img_shapes = self._build_img_shapes(latent_height, latent_width)
# Prepare inpaint extension (operates in 4D space, so unpack/repack around it)
inpaint_mask = self._prep_inpaint_mask(context, noise) # noise has the right 4D shape
inpaint_extension: RectifiedFlowInpaintExtension | None = None
if inpaint_mask is not None:
assert init_latents is not None
inpaint_extension = RectifiedFlowInpaintExtension(
init_latents=init_latents,
inpaint_mask=inpaint_mask,
noise=noise,
)
step_callback = self._build_step_callback(context)
step_callback(
PipelineIntermediateState(
step=0,
order=1,
total_steps=total_steps,
timestep=int(timesteps_sched[0].item()) if len(timesteps_sched) > 0 else 0,
latents=self._unpack_latents(latents, latent_height, latent_width),
),
)
noisy_seq_len = latents.shape[1]
# Determine if the model is quantized — GGUF models need sidecar patching for LoRAs
transformer_config = context.models.get_config(self.transformer.transformer)
model_is_quantized = transformer_config.format in (ModelFormat.GGUFQuantized,)
with ExitStack() as exit_stack:
(cached_weights, transformer) = exit_stack.enter_context(transformer_info.model_on_device())
assert isinstance(transformer, QwenImageTransformer2DModel)
# Apply LoRA patches to the transformer
exit_stack.enter_context(
LayerPatcher.apply_smart_model_patches(
model=transformer,
patches=self._lora_iterator(context),
prefix=QWEN_IMAGE_EDIT_LORA_TRANSFORMER_PREFIX,
dtype=inference_dtype,
cached_weights=cached_weights,
force_sidecar_patching=model_is_quantized,
)
)
for step_idx, t in enumerate(tqdm(timesteps_sched)):
# The pipeline passes timestep / 1000 to the transformer
timestep = t.expand(latents.shape[0]).to(inference_dtype)
# For edit models: concatenate noisy and reference patches along the sequence dim
# For txt2img models: just use noisy patches
if ref_latents_packed is not None:
model_input = torch.cat([latents, ref_latents_packed], dim=1)
else:
model_input = latents
noise_pred_cond = transformer(
hidden_states=model_input,
encoder_hidden_states=pos_prompt_embeds,
encoder_hidden_states_mask=pos_prompt_mask,
timestep=timestep / 1000,
img_shapes=img_shapes,
return_dict=False,
)[0]
# Only keep the noisy-latent portion of the output
noise_pred_cond = noise_pred_cond[:, :noisy_seq_len]
if do_classifier_free_guidance and neg_prompt_embeds is not None:
noise_pred_uncond = transformer(
hidden_states=model_input,
encoder_hidden_states=neg_prompt_embeds,
encoder_hidden_states_mask=neg_prompt_mask,
timestep=timestep / 1000,
img_shapes=img_shapes,
return_dict=False,
)[0]
noise_pred_uncond = noise_pred_uncond[:, :noisy_seq_len]
noise_pred = noise_pred_uncond + cfg_scale[step_idx] * (noise_pred_cond - noise_pred_uncond)
else:
noise_pred = noise_pred_cond
# Euler step using the (possibly clipped) sigma schedule
sigma_curr = sigmas_sched[step_idx]
sigma_next = sigmas_sched[step_idx + 1]
dt = sigma_next - sigma_curr
latents = latents.to(torch.float32) + dt * noise_pred.to(torch.float32)
latents = latents.to(inference_dtype)
if inpaint_extension is not None:
sigma_next = sigmas_sched[step_idx + 1].item()
latents_4d = self._unpack_latents(latents, latent_height, latent_width)
latents_4d = inpaint_extension.merge_intermediate_latents_with_init_latents(latents_4d, sigma_next)
latents = self._pack_latents(latents_4d, 1, out_channels, latent_height, latent_width)
step_callback(
PipelineIntermediateState(
step=step_idx + 1,
order=1,
total_steps=total_steps,
timestep=int(t.item()),
latents=self._unpack_latents(latents, latent_height, latent_width),
),
)
# Unpack back to 4D then add frame dim for the video-style VAE: (B, C, 1, H, W)
latents = self._unpack_latents(latents, latent_height, latent_width)
latents = latents.unsqueeze(2)
return latents
def _build_step_callback(self, context: InvocationContext) -> Callable[[PipelineIntermediateState], None]:
def step_callback(state: PipelineIntermediateState) -> None:
context.util.sd_step_callback(state, BaseModelType.QwenImage)
return step_callback
def _lora_iterator(self, context: InvocationContext) -> Iterator[Tuple[ModelPatchRaw, float]]:
"""Iterate over LoRA models to apply to the transformer."""
for lora in self.transformer.loras:
lora_info = context.models.load(lora.lora)
if not isinstance(lora_info.model, ModelPatchRaw):
raise TypeError(
f"Expected ModelPatchRaw for LoRA '{lora.lora.key}', got {type(lora_info.model).__name__}."
)
yield (lora_info.model, lora.weight)
del lora_info