huggingface / huggingface/diffusers

`qwenimage` model/pipeline review

Open
#13,581 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Python
Stars
34.5k
Forks
7.3k
Avg merge
3d 3h
Merged PRs (30d)
91

Description

# `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:
https://github.com/huggingface/diffusers/blob/0f1abc4ae8b0eb2a3b40e82a310507281144c423/src/diffusers/pipelines/qwenimage/__init__.py#L15-L25

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:
```python
for name in ["QwenImagePriorReduxPipelineOutput", "ReduxImageEncoder"]:
try:
ns = {}
exec(f"from diffusers.pipelines.qwenimage import {name}", ns)
print(name, "ok")
except Exception as e:
print(name, type(e).__name__, e)
```

Relevant precedent:
`ReduxImageEncoder` exists under Flux, not QwenImage.

Suggested fix:
```python
_import_structure = {"pipeline_output": ["QwenImagePipelineOutput"]}

# Remove:
# _import_structure["modeling_qwenimage"] = ["ReduxImageEncoder"]
```

## Issue 2: `guidance_embeds=True` transformer path always raises

Affected code:
https://github.com/huggingface/diffusers/blob/0f1abc4ae8b0eb2a3b40e82a310507281144c423/src/diffusers/models/transformers/transformer_qwenimage.py#L932-L938

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:
```python
import torch
from diffusers import QwenImageTransformer2DModel

model = 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:
https://github.com/huggingface/diffusers/blob/0f1abc4ae8b0eb2a3b40e82a310507281144c423/src/diffusers/models/transformers/transformer_flux.py#L600-L604

Suggested fix:
Add a guidance embedder or remove the unsupported config path. A small local fix would make the embedding signature explicit:
```python
def forward(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_emb

if guidance is not None:
guidance_emb = self.guidance_embedder(self.time_proj(guidance).to(dtype=hidden_states.dtype))
conditioning = conditioning + guidance_emb

if self.addition_time_embedder is not None:
conditioning = conditioning + self.addition_time_embedder(addition_t_cond)

return conditioning
```

## Issue 3: Prompt masks are duplicated in the wrong order

Affected code:
https://github.com/huggingface/diffusers/blob/0f1abc4ae8b0eb2a3b40e82a310507281144c423/src/diffusers/pipelines/qwenimage/pipeline_qwenimage.py#L256-L264

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:
```python
import torch
from diffusers import QwenImagePipeline

pipe = 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:
```python
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:
https://github.com/huggingface/diffusers/blob/0f1abc4ae8b0eb2a3b40e82a310507281144c423/src/diffusers/models/transformers/transformer_qwenimage.py#L912-L938

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:
```python
import torch
from diffusers import QwenImageTransformer2DModel

model = 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:
```python
if self.zero_cond_t:
timestep = torch.cat([timestep, timestep * 0], dim=0)
if additional_t_cond is not None:
additional_t_cond = torch.cat([additional_t_cond, additional_t_cond], dim=0)
```

## Issue 5: Tiled QwenImage VAE decode skips output clamping

Affected code:
https://github.com/huggingface/diffusers/blob/0f1abc4ae8b0eb2a3b40e82a310507281144c423/src/diffusers/models/autoencoders/autoencoder_kl_qwenimage.py#L1030-L1033

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:
```python
import torch
from diffusers import AutoencoderKLQwenImage

vae = AutoencoderKLQwenImage(
base_dim=4, z_dim=1, dim_mult=[1], num_res_blocks=1,
temperal_downsample=[], latents_mean=[0.0], latents_std=[1.0],
)

with torch.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).sample

vae.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).sample

print(plain.max().item(), tiled.max().item())
```

Relevant precedent:
Wan’s tiled VAE decode clamps after tiling:
https://github.com/huggingface/diffusers/blob/0f1abc4ae8b0eb2a3b40e82a310507281144c423/src/diffusers/models/autoencoders/autoencoder_kl_wan.py#L1398-L1403

Suggested fix:
```python
dec = self.blend_v(a, b, blend_extent)
dec = torch.clamp(dec, min=-1.0, max=1.0)
return DecoderOutput(sample=dec)
```

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

Affected code:
https://github.com/huggingface/diffusers/blob/0f1abc4ae8b0eb2a3b40e82a310507281144c423/src/diffusers/pipelines/qwenimage/pipeline_qwenimage_edit.py#L673-L675

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:
```python
import torch
from diffusers import QwenImageEditPipeline

pipe = 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:
```python
def _get_image_size(image):
if isinstance(image, torch.Tensor):
return int(image.shape[-1]), int(image.shape[-2])
return image.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:
https://github.com/huggingface/diffusers/blob/0f1abc4ae8b0eb2a3b40e82a310507281144c423/src/diffusers/pipelines/qwenimage/pipeline_qwenimage_layered.py#L872-L908

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:
```python
def same_tail(output_type, latents):
if output_type == "latent":
image = latents
else:
images = []
return images

same_tail("latent", object())
```

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

Suggested fix:
```python
if output_type == "latent":
images = latents
else:
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:
https://github.com/huggingface/diffusers/blob/0f1abc4ae8b0eb2a3b40e82a310507281144c423/src/diffusers/pipelines/qwenimage/__init__.py#L26-L34
https://github.com/huggingface/diffusers/blob/0f1abc4ae8b0eb2a3b40e82a310507281144c423/src/diffusers/modular_pipelines/qwenimage/__init__.py#L24-L32

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:
```python
from pathlib import Path

pipeline_tests = {p.name for p in Path("tests/pipelines/qwenimage").glob("test_*.py")}
print(sorted(pipeline_tests))

for expected in [
"test_qwenimage_controlnet_inpaint.py",
"test_qwenimage_edit_inpaint.py",
"test_qwenimage_layered.py",
]:
assert expected in pipeline_tests, expected

slow_qwen_tests = [
str(p)
for p in Path("tests").rglob("*.py")
if "qwen" in str(p).lower()
and ("@slow" in p.read_text(errors="ignore") or "slow(" in p.read_text(errors="ignore"))
]
assert slow_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.

Contributor guide

Open the contributing guide

Research direction

Split the review into individual fixes and begin with the affected files under src/diffusers/pipelines/qwenimage, src/diffusers/models/transformers, and src/diffusers/models/autoencoders. Run the provided reproductions, then inspect the existing tests in tests/pipelines/qwenimage and the referenced model tests. Done means each reported failure is fixed and covered by a focused regression test, including the listed export and coverage gaps.

Written by the indexing model from the issue text.

Assessment

Tech stack
python, pytorch
Domain
machine-learning, testing-qa
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.