Skip to content

qwenimage model/pipeline review #13581

Description

@hlky

qwenimage model/pipeline review

Commit tested: 0f1abc4ae8b0eb2a3b40e82a310507281144c423

Review performed against the repository review rules. Duplicate searches were run against huggingface/diffusers issues and PRs for qwenimage, affected classes/files, and the failure modes below. No exact duplicates were found; related but non-identical issues include #12075, #12294, #12458, #12698, and broad issue #12295.

Issue 1: Broken qwenimage lazy exports

Affected code:

_import_structure= {"pipeline_output": ["QwenImagePipelineOutput", "QwenImagePriorReduxPipelineOutput"]}
try:
ifnot (is_transformers_available() andis_torch_available()):
raiseOptionalDependencyNotAvailable()
exceptOptionalDependencyNotAvailable:
from ...utilsimportdummy_torch_and_transformers_objects# noqa F403
_dummy_objects.update(get_objects_from_module(dummy_torch_and_transformers_objects))
else:
_import_structure["modeling_qwenimage"] = ["ReduxImageEncoder"]

Problem:
diffusers.pipelines.qwenimage.__init__ exports QwenImagePriorReduxPipelineOutput from pipeline_output.py, but that class does not exist. It also lazily exports ReduxImageEncoder from modeling_qwenimage, but there is no src/diffusers/pipelines/qwenimage/modeling_qwenimage.py.

Impact:
Subpackage imports fail at runtime and lazy-loading advertises unavailable objects.

Reproduction:

fornamein ["QwenImagePriorReduxPipelineOutput", "ReduxImageEncoder"]:
try:
ns= {}
exec(f"from diffusers.pipelines.qwenimage import {name}", ns)
print(name, "ok")
exceptExceptionase:
print(name, type(e).__name__, e)

Relevant precedent:
ReduxImageEncoder exists under Flux, not QwenImage.

Suggested fix:

_import_structure= {"pipeline_output": ["QwenImagePipelineOutput"]}
# Remove:# _import_structure["modeling_qwenimage"] = ["ReduxImageEncoder"]

Issue 2: guidance_embeds=True transformer path always raises

Affected code:

ifguidanceisnotNone:
guidance=guidance.to(hidden_states.dtype) *1000
temb= (
self.time_text_embed(timestep, hidden_states, additional_t_cond)
ifguidanceisNone
elseself.time_text_embed(timestep, guidance, hidden_states, additional_t_cond)

Problem:
When guidance is passed, QwenImageTransformer2DModel.forward calls self.time_text_embed(timestep, guidance, hidden_states, additional_t_cond), but QwenTimestepProjEmbeddings.forward accepts only (timestep, hidden_states, addition_t_cond=None).

Impact:
Any guidance-distilled QwenImage transformer configuration crashes before denoising.

Reproduction:

importtorchfromdiffusersimportQwenImageTransformer2DModelmodel=QwenImageTransformer2DModel(
patch_size=1, in_channels=4, out_channels=4, num_layers=1,
attention_head_dim=4, num_attention_heads=1, joint_attention_dim=8,
axes_dims_rope=(2, 2, 4),
)
model(
hidden_states=torch.randn(1, 4, 4),
encoder_hidden_states=torch.randn(1, 3, 8),
encoder_hidden_states_mask=torch.ones(1, 3, dtype=torch.bool),
timestep=torch.tensor([1]),
img_shapes=[(1, 2, 2)],
guidance=torch.tensor([1.0]),
)

Relevant precedent:
Flux uses a guidance-aware embedding module:

text_time_guidance_cls= (
CombinedTimestepGuidanceTextProjEmbeddingsifguidance_embedselseCombinedTimestepTextProjEmbeddings
)
self.time_text_embed=text_time_guidance_cls(
embedding_dim=self.inner_dim, pooled_projection_dim=pooled_projection_dim

Suggested fix:
Add a guidance embedder or remove the unsupported config path. A small local fix would make the embedding signature explicit:

defforward(self, timestep, hidden_states, addition_t_cond=None, guidance=None):
timesteps_emb=self.timestep_embedder(self.time_proj(timestep).to(dtype=hidden_states.dtype))
conditioning=timesteps_embifguidanceisnotNone:
guidance_emb=self.guidance_embedder(self.time_proj(guidance).to(dtype=hidden_states.dtype))
conditioning=conditioning+guidance_embifself.addition_time_embedderisnotNone:
conditioning=conditioning+self.addition_time_embedder(addition_t_cond)
returnconditioning

Issue 3: Prompt masks are duplicated in the wrong order

Affected code:

prompt_embeds=prompt_embeds[:, :max_sequence_length]
_, seq_len, _=prompt_embeds.shape
prompt_embeds=prompt_embeds.repeat(1, num_images_per_prompt, 1)
prompt_embeds=prompt_embeds.view(batch_size*num_images_per_prompt, seq_len, -1)
ifprompt_embeds_maskisnotNone:
prompt_embeds_mask=prompt_embeds_mask[:, :max_sequence_length]
prompt_embeds_mask=prompt_embeds_mask.repeat(1, num_images_per_prompt, 1)
prompt_embeds_mask=prompt_embeds_mask.view(batch_size*num_images_per_prompt, seq_len)

Problem:
prompt_embeds are duplicated as [p0, p0, p1, p1], but 2D prompt_embeds_mask.repeat(1, num_images_per_prompt, 1).view(...) produces [p0, p1, p0, p1]. The same pattern appears across QwenImage standard pipelines and modular inputs.

Impact:
For batched prompts with num_images_per_prompt > 1, text attention masks can be paired with the wrong prompt embeddings, causing incorrect conditioning.

Reproduction:

importtorchfromdiffusersimportQwenImagePipelinepipe=object.__new__(QwenImagePipeline)
embeds=torch.arange(2*4, dtype=torch.float32).view(2, 4, 1)
mask=torch.tensor([[1, 1, 0, 0], [1, 0, 1, 0]], dtype=torch.bool)
expanded_embeds, expanded_mask=QwenImagePipeline.encode_prompt(
pipe,
prompt=["a", "b"],
device=torch.device("cpu"),
num_images_per_prompt=2,
prompt_embeds=embeds,
prompt_embeds_mask=mask,
max_sequence_length=4,
)
print(expanded_embeds[:, :, 0])
print(expanded_mask)
print(mask.repeat_interleave(2, dim=0))

Relevant precedent:
Related batching/mask reports exist in #12075 and #12458, but neither is this exact mask-order bug.

Suggested fix:

prompt_embeds=prompt_embeds.repeat_interleave(num_images_per_prompt, dim=0)
prompt_embeds_mask=prompt_embeds_mask.repeat_interleave(num_images_per_prompt, dim=0)
negative_prompt_embeds_mask=negative_prompt_embeds_mask.repeat_interleave(num_images_per_prompt, dim=0)

Issue 4: Layered zero-conditioned transformer fails for batch size greater than one

Affected code:

timestep=timestep.to(hidden_states.dtype)
ifself.zero_cond_t:
timestep=torch.cat([timestep, timestep*0], dim=0)
modulate_index=torch.tensor(
[[0] *prod(sample[0]) + [1] *sum([prod(s) forsinsample[1:]]) forsampleinimg_shapes],
device=timestep.device,
dtype=torch.int,
)
else:
modulate_index=None
encoder_hidden_states=self.txt_norm(encoder_hidden_states)
encoder_hidden_states=self.txt_in(encoder_hidden_states)
# Use the encoder_hidden_states sequence length for RoPE computation and normalize mask
text_seq_len, _, encoder_hidden_states_mask=compute_text_seq_len_from_mask(
encoder_hidden_states, encoder_hidden_states_mask
)
ifguidanceisnotNone:
guidance=guidance.to(hidden_states.dtype) *1000
temb= (
self.time_text_embed(timestep, hidden_states, additional_t_cond)
ifguidanceisNone
elseself.time_text_embed(timestep, guidance, hidden_states, additional_t_cond)

Problem:
When zero_cond_t=True, timestep is doubled, but additional_t_cond is not. With use_additional_t_cond=True, the timestep embedding has batch 2B while the additional condition embedding still has batch B.

Impact:
Layered QwenImage transformer variants fail for batched inputs.

Reproduction:

importtorchfromdiffusersimportQwenImageTransformer2DModelmodel=QwenImageTransformer2DModel(
patch_size=1, in_channels=4, out_channels=4, num_layers=1,
attention_head_dim=4, num_attention_heads=1, joint_attention_dim=8,
axes_dims_rope=(2, 2, 4),
zero_cond_t=True,
use_additional_t_cond=True,
use_layer3d_rope=True,
)
model(
hidden_states=torch.randn(2, 8, 4),
encoder_hidden_states=torch.randn(2, 3, 8),
encoder_hidden_states_mask=torch.ones(2, 3, dtype=torch.bool),
timestep=torch.tensor([1.0, 1.0]),
img_shapes=[[(1, 2, 2), (1, 2, 2)], [(1, 2, 2), (1, 2, 2)]],
additional_t_cond=torch.tensor([0, 1], dtype=torch.long),
)

Relevant precedent:
No exact duplicate found.

Suggested fix:

ifself.zero_cond_t:
timestep=torch.cat([timestep, timestep*0], dim=0)
ifadditional_t_condisnotNone:
additional_t_cond=torch.cat([additional_t_cond, additional_t_cond], dim=0)

Issue 5: Tiled QwenImage VAE decode skips output clamping

Affected code:

dec=torch.cat(result_rows, dim=3)[:, :, :, :sample_height, :sample_width]
ifnotreturn_dict:
return (dec,)

Problem:
Regular _decode clamps decoded samples to [-1, 1], but tiled_decode returns the blended tensor without clamping.

Impact:
The same latent can produce different value ranges depending on whether VAE tiling is enabled.

Reproduction:

importtorchfromdiffusersimportAutoencoderKLQwenImagevae=AutoencoderKLQwenImage(
base_dim=4, z_dim=1, dim_mult=[1], num_res_blocks=1,
temperal_downsample=[], latents_mean=[0.0], latents_std=[1.0],
)
withtorch.no_grad():
vae.decoder.conv_out.weight.zero_()
vae.decoder.conv_out.bias.fill_(2.0)
z=torch.zeros(1, 1, 1, 8, 8)
plain=vae.decode(z).samplevae.enable_tiling(
tile_sample_min_height=4,
tile_sample_min_width=4,
tile_sample_stride_height=4,
tile_sample_stride_width=4,
)
tiled=vae.decode(z).sampleprint(plain.max().item(), tiled.max().item())

Relevant precedent:
Wan’s tiled VAE decode clamps after tiling:

dec=torch.cat(result_rows, dim=3)[:, :, :, :sample_height, :sample_width]
ifself.config.patch_sizeisnotNone:
dec=unpatchify(dec, patch_size=self.config.patch_size)
dec=torch.clamp(dec, min=-1.0, max=1.0)

Suggested fix:

dec=self.blend_v(a, b, blend_extent)
dec=torch.clamp(dec, min=-1.0, max=1.0)
returnDecoderOutput(sample=dec)

Issue 6: Tensor image inputs crash before preprocessing in edit-family pipelines

Affected code:

image_size=image[0].sizeifisinstance(image, list) elseimage.size
calculated_width, calculated_height, _=calculate_dimensions(1024*1024, image_size[0] /image_size[1])
height=heightorcalculated_height

Problem:
Several QwenImage image-conditioned pipelines read image.size as if it were a PIL tuple before preprocessing. For torch.Tensor, image.size is a method, so indexing it crashes.

Impact:
Documented tensor image inputs are rejected before the pipeline image processor can normalize them. The same pattern appears in edit-inpaint, edit-plus, layered, and modular encoders.

Reproduction:

importtorchfromdiffusersimportQwenImageEditPipelinepipe=object.__new__(QwenImageEditPipeline)
QwenImageEditPipeline.__call__(
pipe,
image=torch.zeros(1, 3, 32, 32),
prompt_embeds=torch.zeros(1, 4, 8),
prompt_embeds_mask=torch.ones(1, 4, dtype=torch.bool),
true_cfg_scale=1.0,
num_inference_steps=1,
output_type="latent",
)

Relevant precedent:
Related batch/image handling work exists in #12458 and #12698, but this tensor .size crash is broader.

Suggested fix:

def_get_image_size(image):
ifisinstance(image, torch.Tensor):
returnint(image.shape[-1]), int(image.shape[-2])
returnimage.size

Use this helper before resizing logic and apply it consistently for list/tuple image inputs.

Issue 7: QwenImageLayeredPipeline(output_type="latent") returns an undefined variable

Affected code:

ifoutput_type=="latent":
image=latents
else:
latents=self._unpack_latents(latents, height, width, layers, self.vae_scale_factor)
latents=latents.to(self.vae.dtype)
latents_mean= (
torch.tensor(self.vae.config.latents_mean)
.view(1, self.vae.config.z_dim, 1, 1, 1)
.to(latents.device, latents.dtype)
)
latents_std=1.0/torch.tensor(self.vae.config.latents_std).view(1, self.vae.config.z_dim, 1, 1, 1).to(
latents.device, latents.dtype
)
latents=latents/latents_std+latents_mean
b, c, f, h, w=latents.shape
latents=latents[:, :, 1:] # remove the first frame as it is the orgin input
latents=latents.permute(0, 2, 1, 3, 4).reshape(-1, c, 1, h, w)
image=self.vae.decode(latents, return_dict=False)[0] # (b f) c 1 h w
image=image.squeeze(2)
image=self.image_processor.postprocess(image, output_type=output_type)
images= []
forbidxinrange(b):
images.append(image[bidx*f : (bidx+1) *f])
# Offload all models
self.maybe_free_model_hooks()
ifnotreturn_dict:
return (images,)
returnQwenImagePipelineOutput(images=images)

Problem:
The latent branch assigns image = latents, but the return path always returns images. images is only assigned in the decode branch.

Impact:
output_type="latent" raises UnboundLocalError instead of returning latents.

Reproduction:

defsame_tail(output_type, latents):
ifoutput_type=="latent":
image=latentselse:
images= []
returnimagessame_tail("latent", object())

Relevant precedent:
Other QwenImage pipelines assign and return the same variable in the latent branch.

Suggested fix:

ifoutput_type=="latent":
images=latentselse:
latents=latents.to(self.vae.dtype)
latents_mean= ...
images=self.vae.decode(latents, return_dict=False)[0]

Issue 8: Test coverage gaps for exported QwenImage variants

Affected code:

_import_structure["pipeline_qwenimage"] = ["QwenImagePipeline"]
_import_structure["pipeline_qwenimage_controlnet"] = ["QwenImageControlNetPipeline"]
_import_structure["pipeline_qwenimage_controlnet_inpaint"] = ["QwenImageControlNetInpaintPipeline"]
_import_structure["pipeline_qwenimage_edit"] = ["QwenImageEditPipeline"]
_import_structure["pipeline_qwenimage_edit_inpaint"] = ["QwenImageEditInpaintPipeline"]
_import_structure["pipeline_qwenimage_edit_plus"] = ["QwenImageEditPlusPipeline"]
_import_structure["pipeline_qwenimage_img2img"] = ["QwenImageImg2ImgPipeline"]
_import_structure["pipeline_qwenimage_inpaint"] = ["QwenImageInpaintPipeline"]
_import_structure["pipeline_qwenimage_layered"] = ["QwenImageLayeredPipeline"]

_import_structure["modular_blocks_qwenimage"] = ["QwenImageAutoBlocks"]
_import_structure["modular_blocks_qwenimage_edit"] = ["QwenImageEditAutoBlocks"]
_import_structure["modular_blocks_qwenimage_edit_plus"] = ["QwenImageEditPlusAutoBlocks"]
_import_structure["modular_blocks_qwenimage_layered"] = ["QwenImageLayeredAutoBlocks"]
_import_structure["modular_pipeline"] = [
"QwenImageEditModularPipeline",
"QwenImageEditPlusModularPipeline",
"QwenImageLayeredModularPipeline",
"QwenImageModularPipeline",

Problem:
Fast tests exist for base, img2img, inpaint, edit, edit-plus, controlnet, transformer, LoRA, and several modular workflows. Missing coverage includes slow tests for the QwenImage family, fast standard tests for QwenImageControlNetInpaintPipeline, QwenImageEditInpaintPipeline, and QwenImageLayeredPipeline, direct model tests for AutoencoderKLQwenImage and QwenImageControlNetModel, and modular layered tests.

Impact:
Several exported public classes can regress without CI coverage. The missing layered tests would have caught the latent-return and batch additional-condition bugs above.

Reproduction:

frompathlibimportPathpipeline_tests= {p.nameforpinPath("tests/pipelines/qwenimage").glob("test_*.py")}
print(sorted(pipeline_tests))
forexpectedin [
"test_qwenimage_controlnet_inpaint.py",
"test_qwenimage_edit_inpaint.py",
"test_qwenimage_layered.py",
]:
assertexpectedinpipeline_tests, expectedslow_qwen_tests= [
str(p)
forpinPath("tests").rglob("*.py")
if"qwen"instr(p).lower()
and ("@slow"inp.read_text(errors="ignore") or"slow("inp.read_text(errors="ignore"))
]
assertslow_qwen_tests, "no qwenimage slow tests found"

Relevant precedent:
Other pipeline families generally carry both fast dummy tests and at least one slow smoke test for public pipelines.

Suggested fix:
Add fast dummy tests for every exported standard and modular QwenImage pipeline variant, direct model tests for the VAE and ControlNet, and at least one @slow smoke test per public workflow class or shared slow test coverage that instantiates each exported variant.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions